From 9898e76a80327d6d0978551218f6e7bdfe47be2c Mon Sep 17 00:00:00 2001 From: Ambroise Maupate Date: Mon, 6 Mar 2023 12:26:56 +0100 Subject: [PATCH] feat: Added sub-package code --- lib/DocGenerator | 1 - .../.github/workflows/run-test.yml | 41 + lib/DocGenerator/.gitignore | 8 + lib/DocGenerator/.travis.yml | 15 + lib/DocGenerator/CHANGELOG.md | 6 + lib/DocGenerator/LICENSE | 21 + lib/DocGenerator/Makefile | 4 + lib/DocGenerator/README.md | 4 + lib/DocGenerator/composer.json | 35 + lib/DocGenerator/phpcs.xml.dist | 17 + lib/DocGenerator/phpstan.neon | 7 + .../src/Generators/AbstractFieldGenerator.php | 68 + .../Generators/ChildrenNodeFieldGenerator.php | 44 + .../src/Generators/CommonFieldGenerator.php | 15 + .../DefaultValuedFieldGenerator.php | 25 + .../src/Generators/DocumentationGenerator.php | 112 + .../Generators/MarkdownGeneratorFactory.php | 65 + .../NodeReferencesFieldGenerator.php | 17 + .../src/Generators/NodeTypeGenerator.php | 97 + .../Resources/translations/messages.ar.xlf | 21 + .../Resources/translations/messages.de.xlf | 21 + .../Resources/translations/messages.en.xlf | 63 + .../Resources/translations/messages.es.xlf | 21 + .../Resources/translations/messages.fr.xlf | 63 + .../Resources/translations/messages.id.xlf | 21 + .../Resources/translations/messages.it.xlf | 21 + .../Resources/translations/messages.ru.xlf | 21 + .../Resources/translations/messages.sr.xlf | 21 + .../Resources/translations/messages.tr.xlf | 21 + .../Resources/translations/messages.uk.xlf | 21 + .../src/Resources/translations/messages.xlf | 63 + .../Resources/translations/messages.zh.xlf | 63 + lib/Documents | 1 - lib/Documents/.editorconfig | 17 + lib/Documents/.github/workflows/run-test.yml | 43 + lib/Documents/.gitignore | 110 + lib/Documents/.travis.yml | 19 + lib/Documents/CHANGELOG.md | 56 + lib/Documents/LICENSE.md | 9 + lib/Documents/Makefile | 13 + lib/Documents/README.md | 20 + lib/Documents/composer.json | 69 + lib/Documents/docker-compose.yml | 11 + lib/Documents/files/folder/file.svg | 4 + lib/Documents/phpcs.xml.dist | 13 + lib/Documents/phpstan.neon | 24 + lib/Documents/src/AbstractDocumentFactory.php | 308 + lib/Documents/src/AbstractDocumentFinder.php | 67 + lib/Documents/src/ArrayDocumentFinder.php | 65 + lib/Documents/src/AverageColorResolver.php | 33 + .../src/Console/AbstractDocumentCommand.php | 89 + .../Console/DocumentAverageColorCommand.php | 58 + .../Console/DocumentClearFolderCommand.php | 91 + .../src/Console/DocumentDownscaleCommand.php | 97 + .../src/Console/DocumentDuplicatesCommand.php | 53 + .../src/Console/DocumentFileHashCommand.php | 84 + .../src/Console/DocumentFilesizeCommand.php | 48 + .../src/Console/DocumentPruneCommand.php | 72 + .../Console/DocumentPruneOrphansCommand.php | 78 + .../src/Console/DocumentSizeCommand.php | 67 + lib/Documents/src/DocumentArchiver.php | 80 + lib/Documents/src/DocumentFinderInterface.php | 52 + lib/Documents/src/DownloadedFile.php | 116 + lib/Documents/src/DownscaleImageManager.php | 313 + .../Events/CachePurgeAssetsRequestEvent.php | 11 + .../src/Events/DocumentCreatedEvent.php | 12 + .../src/Events/DocumentDeletedEvent.php | 12 + .../src/Events/DocumentFileUpdatedEvent.php | 12 + .../src/Events/DocumentInFolderEvent.php | 9 + .../Events/DocumentLifeCycleSubscriber.php | 258 + .../src/Events/DocumentOutFolderEvent.php | 9 + .../src/Events/DocumentUpdatedEvent.php | 12 + .../src/Events/FilterDocumentEvent.php | 23 + .../APINeedsAuthentificationException.php | 12 + .../DocumentWithoutFileException.php | 26 + .../EmbedDocumentAlreadyExistsException.php | 18 + .../src/Exceptions/InvalidEmbedId.php | 36 + .../AbstractDailymotionEmbedFinder.php | 168 + .../AbstractDeezerEmbedFinder.php | 168 + .../src/MediaFinders/AbstractEmbedFinder.php | 462 + .../AbstractMixcloudEmbedFinder.php | 148 + .../MediaFinders/AbstractPodcastFinder.php | 242 + .../AbstractSoundcloudEmbedFinder.php | 157 + .../AbstractSpotifyEmbedFinder.php | 156 + .../MediaFinders/AbstractTedEmbedFinder.php | 96 + .../AbstractTwitchEmbedFinder.php | 113 + .../AbstractUnsplashPictureFinder.php | 198 + .../MediaFinders/AbstractVimeoEmbedFinder.php | 174 + .../AbstractYoutubeEmbedFinder.php | 223 + .../src/MediaFinders/EmbedFinderFactory.php | 89 + .../src/MediaFinders/EmbedFinderInterface.php | 31 + .../MediaFinders/FacebookPictureFinder.php | 42 + .../src/MediaFinders/RandomImageFinder.php | 21 + .../src/Models/AdvancedDocumentInterface.php | 30 + .../src/Models/DisplayableInterface.php | 19 + .../src/Models/DocumentInterface.php | 204 + lib/Documents/src/Models/DocumentTrait.php | 309 + .../src/Models/FileAwareInterface.php | 51 + .../src/Models/FileHashInterface.php | 13 + lib/Documents/src/Models/FolderInterface.php | 65 + .../src/Models/HasThumbnailInterface.php | 47 + lib/Documents/src/Models/SimpleDocument.php | 191 + lib/Documents/src/Models/SimpleFileAware.php | 61 + .../src/Models/SizeableInterface.php | 35 + .../src/Models/TimeableInterface.php | 19 + .../OptionsResolver/UrlOptionsResolver.php | 111 + .../OptionsResolver/ViewOptionsResolver.php | 139 + lib/Documents/src/Packages.php | 366 + .../src/Renderer/AbstractImageRenderer.php | 188 + .../src/Renderer/AbstractRenderer.php | 104 + lib/Documents/src/Renderer/AudioRenderer.php | 72 + lib/Documents/src/Renderer/ChainRenderer.php | 61 + lib/Documents/src/Renderer/EmbedRenderer.php | 52 + lib/Documents/src/Renderer/ImageRenderer.php | 63 + .../src/Renderer/InlineSvgRenderer.php | 57 + lib/Documents/src/Renderer/PdfRenderer.php | 36 + .../src/Renderer/PictureRenderer.php | 88 + .../src/Renderer/RendererInterface.php | 26 + lib/Documents/src/Renderer/SvgRenderer.php | 76 + .../src/Renderer/ThumbnailRenderer.php | 60 + lib/Documents/src/Renderer/VideoRenderer.php | 126 + .../DocumentRepositoryInterface.php | 30 + .../Resources/views/documents/audio.html.twig | 54 + .../Resources/views/documents/image.html.twig | 85 + .../Resources/views/documents/pdf.html.twig | 37 + .../views/documents/picture-inner.html.twig | 17 + .../views/documents/picture-source.html.twig | 62 + .../views/documents/picture.html.twig | 14 + .../Resources/views/documents/video.html.twig | 67 + lib/Documents/src/SvgSizeResolver.php | 143 + .../src/TwigExtension/DocumentExtension.php | 245 + .../AbstractDocumentUrlGenerator.php | 122 + .../DocumentUrlGeneratorInterface.php | 31 + .../DummyDocumentUrlGenerator.php | 49 + .../src/UrlGenerators/OptionsCompiler.php | 93 + .../src/Viewers/SvgDocumentViewer.php | 184 + .../Document/Renderer/AudioRenderer.php | 207 + .../Document/Renderer/ChainRenderer.php | 169 + .../Document/Renderer/EmbedRenderer.php | 180 + .../Document/Renderer/ImageRenderer.php | 437 + .../Document/Renderer/InlineSvgRenderer.php | 100 + .../Roadiz/Document/Renderer/PdfRenderer.php | 129 + .../Document/Renderer/PictureRenderer.php | 921 ++ .../Roadiz/Document/Renderer/SvgRenderer.php | 95 + .../Document/Renderer/VideoRenderer.php | 216 + lib/DtsGenerator | 1 - .../.github/workflows/run-test.yml | 42 + lib/DtsGenerator/.gitignore | 8 + lib/DtsGenerator/.travis.yml | 16 + lib/DtsGenerator/Makefile | 4 + lib/DtsGenerator/README.md | 21 + lib/DtsGenerator/composer.json | 33 + lib/DtsGenerator/phpcs.xml.dist | 17 + lib/DtsGenerator/phpstan.neon | 7 + .../src/DeclarationGeneratorFactory.php | 77 + .../src/Generators/AbstractFieldGenerator.php | 78 + .../Generators/ChildrenNodeFieldGenerator.php | 30 + .../src/Generators/DeclarationGenerator.php | 64 + .../Generators/DocumentsFieldGenerator.php | 18 + .../src/Generators/EnumFieldGenerator.php | 43 + .../NodeReferencesFieldGenerator.php | 57 + .../src/Generators/NodeTypeGenerator.php | 99 + .../src/Generators/ScalarFieldGenerator.php | 37 + lib/EntityGenerator | 1 - lib/EntityGenerator/.editorconfig | 17 + .../.github/workflows/run-test.yml | 43 + lib/EntityGenerator/.gitignore | 5 + lib/EntityGenerator/.travis.yml | 18 + lib/EntityGenerator/CHANGELOG.md | 24 + lib/EntityGenerator/LICENSE | 21 + lib/EntityGenerator/Makefile | 4 + lib/EntityGenerator/README.md | 5 + lib/EntityGenerator/composer.json | 44 + lib/EntityGenerator/phpstan.neon | 11 + .../src/Attribute/AttributeGenerator.php | 78 + .../src/Attribute/AttributeListGenerator.php | 43 + .../src/ClassGeneratorInterface.php | 10 + lib/EntityGenerator/src/EntityGenerator.php | 388 + .../src/EntityGeneratorFactory.php | 52 + .../src/EntityGeneratorInterface.php | 12 + .../AbstractConfigurableFieldGenerator.php | 37 + .../src/Field/AbstractFieldGenerator.php | 422 + .../src/Field/CollectionFieldGenerator.php | 9 + .../src/Field/CustomFormsFieldGenerator.php | 107 + .../src/Field/DocumentsFieldGenerator.php | 117 + .../src/Field/ManyToManyFieldGenerator.php | 144 + .../src/Field/ManyToOneFieldGenerator.php | 102 + .../src/Field/NodesFieldGenerator.php | 171 + .../src/Field/NonVirtualFieldGenerator.php | 258 + .../Field/ProxiedManyToManyFieldGenerator.php | 249 + .../src/Field/YamlFieldGenerator.php | 66 + .../src/RepositoryGenerator.php | 118 + .../src/RepositoryGeneratorInterface.php | 12 + .../mocks/GeneratedNodesSources/NSMock.php | 1121 ++ .../NSMock.php | 1121 ++ .../NSMockRepository.php | 38 + .../tests/mocks/NodeTypeAwareTrait.php | 223 + .../tests/mocks/NodeTypeField.php | 586 + .../tests/units/EntityGenerator.php | 82 + .../tests/units/EntityGeneratorFactory.php | 132 + lib/Jwt | 1 - lib/Jwt/.github/workflows/run-test.yml | 41 + lib/Jwt/.gitignore | 175 + lib/Jwt/.travis.yml | 15 + lib/Jwt/LICENSE | 21 + lib/Jwt/Makefile | 4 + lib/Jwt/README.md | 4 + lib/Jwt/composer.json | 34 + lib/Jwt/phpcs.xml.dist | 14 + lib/Jwt/phpstan.neon | 7 + lib/Jwt/src/JwtConfigurationFactory.php | 12 + .../Validation/Constraint/HostedDomain.php | 41 + .../Constraint/UserInfoEndpoint.php | 42 + lib/Markdown | 1 - lib/Markdown/.editorconfig | 17 + lib/Markdown/.github/workflows/run-test.yml | 41 + lib/Markdown/.gitignore | 14 + lib/Markdown/.travis.yml | 15 + lib/Markdown/CHANGELOG.md | 15 + lib/Markdown/Makefile | 3 + lib/Markdown/README.md | 7 + lib/Markdown/composer.json | 36 + lib/Markdown/docker-compose.yml | 11 + lib/Markdown/phpcs.xml.dist | 14 + lib/Markdown/phpstan.neon | 12 + lib/Markdown/src/CommonMark.php | 79 + lib/Markdown/src/MarkdownInterface.php | 35 + lib/Markdown/src/Twig/MarkdownExtension.php | 69 + lib/Models | 1 - lib/Models/.editorconfig | 17 + lib/Models/.github/workflows/run-test.yml | 43 + lib/Models/.gitignore | 111 + lib/Models/.travis.yml | 17 + lib/Models/CHANGELOG.md | 13 + lib/Models/LICENSE.md | 9 + lib/Models/Makefile | 10 + lib/Models/README.md | 3 + lib/Models/composer.json | 53 + lib/Models/phpcs.xml.dist | 13 + lib/Models/phpstan.neon | 15 + .../src/Roadiz/Bag/LazyParameterBag.php | 122 + .../AbstractEntities/AbstractDateTimed.php | 116 + .../AbstractDateTimedPositioned.php | 34 + .../Core/AbstractEntities/AbstractEntity.php | 50 + .../Core/AbstractEntities/AbstractField.php | 848 ++ .../Core/AbstractEntities/AbstractHuman.php | 244 + .../AbstractEntities/AbstractPositioned.php | 32 + .../Core/AbstractEntities/LeafInterface.php | 53 + .../Core/AbstractEntities/LeafTrait.php | 117 + .../AbstractEntities/PersistableInterface.php | 20 + .../AbstractEntities/PositionedInterface.php | 22 + .../Core/AbstractEntities/PositionedTrait.php | 47 + .../AbstractEntities/TranslationInterface.php | 80 + .../Events/LeafEntityLifeCycleSubscriber.php | 75 + .../Roadiz/Core/Handlers/AbstractHandler.php | 49 + .../Core/Handlers/HandlerFactoryInterface.php | 16 + lib/Models/src/Roadiz/Utils/StringHandler.php | 214 + lib/Models/tests/Utils/StringHandlerTest.php | 341 + lib/Models/tests/bootstrap.php | 36 + lib/OpenId | 1 - lib/OpenId/.editorconfig | 17 + lib/OpenId/.github/workflows/run-test.yml | 41 + lib/OpenId/.gitignore | 175 + lib/OpenId/.travis.yml | 16 + lib/OpenId/CHANGELOG.md | 32 + lib/OpenId/LICENSE | 21 + lib/OpenId/Makefile | 4 + lib/OpenId/README.md | 4 + lib/OpenId/composer.json | 51 + lib/OpenId/phpcs.xml.dist | 14 + lib/OpenId/phpstan.neon | 7 + .../Authentication/OpenIdAuthenticator.php | 251 + .../Provider/ChainJwtRoleStrategy.php | 49 + .../Provider/JwtRoleStrategy.php | 11 + .../Provider/OpenIdAccountProvider.php | 52 + .../Provider/SettingsRoleStrategy.php | 38 + lib/OpenId/src/Discovery.php | 130 + .../DiscoveryNotAvailableException.php | 20 + .../OpenIdAuthenticationException.php | 20 + .../OpenIdConfigurationException.php | 20 + lib/OpenId/src/OAuth2LinkGenerator.php | 98 + .../src/OpenIdJwtConfigurationFactory.php | 100 + lib/OpenId/src/User/OpenIdAccount.php | 309 + lib/Random | 1 - lib/Random/.github/workflows/run-test.yml | 41 + lib/Random/.gitignore | 55 + lib/Random/.travis.yml | 16 + lib/Random/LICENSE | 21 + lib/Random/Makefile | 3 + lib/Random/README.md | 4 + lib/Random/composer.json | 33 + lib/Random/phpcs.xml.dist | 14 + lib/Random/phpstan.neon | 11 + lib/Random/src/PasswordGenerator.php | 46 + lib/Random/src/PasswordGeneratorInterface.php | 14 + lib/Random/src/RandomGenerator.php | 54 + lib/Random/src/SaltGenerator.php | 16 + lib/Random/src/SaltGeneratorInterface.php | 13 + lib/Random/src/TokenGenerator.php | 16 + lib/Random/src/TokenGeneratorInterface.php | 13 + lib/RoadizCompatBundle | 1 - .../.github/workflows/run-test.yml | 41 + lib/RoadizCompatBundle/.gitignore | 126 + lib/RoadizCompatBundle/.travis.yml | 16 + lib/RoadizCompatBundle/CHANGELOG.md | 30 + lib/RoadizCompatBundle/LICENSE.md | 9 + lib/RoadizCompatBundle/Makefile | 3 + lib/RoadizCompatBundle/README.md | 46 + lib/RoadizCompatBundle/composer.json | 66 + .../config/packages/roadiz_compat.yaml | 5 + lib/RoadizCompatBundle/config/services.yaml | 124 + lib/RoadizCompatBundle/deprecated.php | 8 + lib/RoadizCompatBundle/phpcs.xml.dist | 13 + lib/RoadizCompatBundle/phpstan.neon | 33 + lib/RoadizCompatBundle/src/Aliases.php | 281 + .../src/Console/ThemeAssetsCommand.php | 79 + .../src/Console/ThemeGenerateCommand.php | 106 + .../src/Console/ThemeInfoCommand.php | 67 + .../src/Console/ThemeInstallCommand.php | 240 + .../src/Console/ThemeMigrateCommand.php | 138 + .../src/Console/ThemesListCommand.php | 88 + .../src/Controller/AppController.php | 651 + .../src/Controller/Controller.php | 505 + .../src/Controller/FrontendController.php | 433 + .../ThemesTranslatorPathsCompilerPass.php | 87 + .../src/DependencyInjection/Configuration.php | 55 + .../RoadizCompatExtension.php | 103 + .../ControllerEventSubscriber.php | 32 + .../EventSubscriber/ExceptionSubscriber.php | 210 + .../MaintenanceModeSubscriber.php | 147 + .../src/RoadizCompatBundle.php | 24 + .../src/Routing/ThemeAwareNodeRouter.php | 71 + .../src/Routing/ThemeAwareNodeUrlMatcher.php | 76 + .../src/Routing/ThemeRoutesLoader.php | 75 + .../src/Theme/StaticThemeResolver.php | 146 + .../src/Theme/ThemeGenerator.php | 305 + .../src/Theme/ThemeInfo.php | 265 + .../src/Theme/ThemeResolverInterface.php | 51 + lib/RoadizCoreBundle | 1 - .../.github/workflows/run-test.yml | 41 + lib/RoadizCoreBundle/.gitignore | 190 + lib/RoadizCoreBundle/.travis.yml | 14 + lib/RoadizCoreBundle/CHANGELOG.md | 447 + lib/RoadizCoreBundle/LICENSE.md | 9 + lib/RoadizCoreBundle/Makefile | 3 + lib/RoadizCoreBundle/README.md | 61 + lib/RoadizCoreBundle/composer.json | 138 + .../config/api_resources/attribute.yml | 5 + .../config/api_resources/attribute_value.yml | 15 + .../config/api_resources/custom_form.yml | 205 + .../config/api_resources/document.yml | 15 + .../config/api_resources/folder.yml | 11 + .../config/api_resources/node.yml | 10 + .../config/api_resources/nodes_sources.yml | 55 + .../config/api_resources/realm.yml | 11 + .../config/api_resources/tag.yml | 20 + .../config/api_resources/translation.yml | 9 + .../config/api_resources/web_response.yml | 39 + lib/RoadizCoreBundle/config/fixtures.yaml | 7 + .../config/fixtures/roles.json | 249 + .../config/fixtures/settings.json | 308 + .../config/packages/api_platform.yaml | 50 + .../config/packages/doctrine.yaml | 53 + .../config/packages/flysystem.yaml | 51 + .../config/packages/framework.yaml | 51 + .../config/packages/jms_serializer.yaml | 13 + .../config/packages/messenger.yaml | 14 + .../config/packages/monolog.yaml | 30 + .../config/packages/roadiz_core.yaml | 41 + .../config/packages/security.yaml | 78 + .../config/packages/twig.yaml | 7 + lib/RoadizCoreBundle/config/routing.yaml | 39 + lib/RoadizCoreBundle/config/services.yaml | 610 + lib/RoadizCoreBundle/crowdin.yml | 8 + lib/RoadizCoreBundle/css/README.md | 3 + .../css/transactionalStyles.css | 313 + lib/RoadizCoreBundle/manifest.json | 38 + .../migrations/Version20201203004857.php | 151 + .../migrations/Version20201214232628.php | 66 + .../migrations/Version20201225181256.php | 384 + .../migrations/Version20210423072744.php | 34 + .../migrations/Version20210423161606.php | 71 + .../migrations/Version20210423164248.php | 129 + .../migrations/Version20210506085247.php | 77 + .../migrations/Version20210520092543.php | 67 + .../migrations/Version20210527131435.php | 43 + .../migrations/Version20210701151713.php | 42 + .../migrations/Version20210715120118.php | 48 + .../migrations/Version20210802132310.php | 34 + .../migrations/Version20220104132107.php | 36 + .../migrations/Version20220204180955.php | 50 + .../migrations/Version20220525131842.php | 47 + .../migrations/Version20220525150545.php | 38 + .../migrations/Version20220530112008.php | 36 + .../migrations/Version20220530132117.php | 38 + .../migrations/Version20220602173719.php | 40 + .../migrations/Version20220622141902.php | 36 + .../migrations/Version20220623133037.php | 36 + .../migrations/Version20220729100037.php | 36 + .../migrations/Version20220901082425.php | 44 + .../migrations/Version20221007085729.php | 36 + .../migrations/Version20221018195433.php | 50 + .../migrations/Version20221220181250.php | 35 + .../migrations/Version20230125105107.php | 34 + lib/RoadizCoreBundle/phpcs.xml.dist | 13 + lib/RoadizCoreBundle/phpstan.neon | 32 + lib/RoadizCoreBundle/public/assets/.gitkeep | 0 .../public/assets/roadiz_white.jpg | Bin 0 -> 31306 bytes lib/RoadizCoreBundle/public/files/.gitkeep | 0 .../src/Api/Breadcrumbs/Breadcrumbs.php | 34 + .../BreadcrumbsFactoryInterface.php | 12 + .../Api/Breadcrumbs/BreadcrumbsInterface.php | 10 + .../NodesSourcesBreadcrumbsFactory.php | 42 + .../GetWebResponseByPathController.php | 121 + .../NodesSourcesSearchController.php | 82 + .../TranslationAwareControllerTrait.php | 40 + .../AttributeOutputDataTransformer.php | 45 + .../AttributeValueOutputDataTransformer.php | 48 + .../BaseNodesSourcesOutputDataTransformer.php | 35 + ...eWebResponseOutputDataTransformerTrait.php | 46 + .../CustomFormOutputDataTransformer.php | 59 + .../DocumentOutputDataTransformer.php | 96 + .../FolderOutputDataTransformer.php | 49 + .../NodeOutputDataTransformer.php | 39 + .../NodesSourcesOutputDataTransformer.php | 52 + ...eWebResponseOutputDataTransformerTrait.php | 47 + .../TagOutputDataTransformer.php | 53 + .../TranslationOutputDataTransformer.php | 36 + .../WebResponseDataTransformerInterface.php | 28 + .../WebResponseOutputDataTransformer.php | 111 + lib/RoadizCoreBundle/src/Api/Dto/Archive.php | 16 + .../src/Api/Dto/AttributeOutput.php | 51 + .../src/Api/Dto/AttributeValueOutput.php | 41 + .../src/Api/Dto/CustomFormOutput.php | 49 + .../src/Api/Dto/DocumentOutput.php | 109 + .../src/Api/Dto/FolderOutput.php | 34 + .../src/Api/Dto/NodeOutput.php | 34 + .../src/Api/Dto/NodesSourcesDto.php | 59 + .../src/Api/Dto/NodesSourcesOutput.php | 12 + .../src/Api/Dto/TagOutput.php | 57 + .../src/Api/Dto/TranslationOutput.php | 34 + .../src/Api/Extension/ArchiveExtension.php | 155 + .../Api/Extension/DocumentQueryExtension.php | 49 + .../src/Api/Extension/NodeQueryExtension.php | 77 + .../Extension/NodesSourcesQueryExtension.php | 96 + .../src/Api/Filter/ArchiveFilter.php | 158 + .../src/Api/Filter/CopyrightValidFilter.php | 77 + .../src/Api/Filter/GeneratedEntityFilter.php | 50 + .../src/Api/Filter/IntersectionFilter.php | 145 + .../src/Api/Filter/LocaleFilter.php | 134 + .../src/Api/Filter/NotFilter.php | 110 + .../src/Api/ListManager/SolrPaginator.php | 66 + .../Api/ListManager/SolrSearchListManager.php | 101 + .../Model/BlocksAwareWebResponseInterface.php | 22 + .../src/Api/Model/NodesSourcesHead.php | 308 + .../src/Api/Model/NodesSourcesHeadFactory.php | 62 + .../Api/Model/NodesSourcesHeadInterface.php | 21 + .../Model/RealmsAwareWebResponseInterface.php | 32 + .../src/Api/Model/WebResponse.php | 103 + .../src/Api/Model/WebResponseInterface.php | 12 + .../src/Api/OpenApi/JwtDecorator.php | 122 + .../AutoChildrenNodeSourceWalker.php | 65 + .../MultiTypeChildrenDefinition.php | 57 + .../NonReachableNodeSourceBlockDefinition.php | 45 + .../ReachableNodeSourceDefinition.php | 45 + .../TreeWalker/NodeSourceWalkerContext.php | 126 + .../NodeSourceWalkerContextFactory.php | 56 + .../Api/TreeWalker/TreeWalkerGenerator.php | 73 + .../WalkerContextFactoryInterface.php | 12 + lib/RoadizCoreBundle/src/Bag/NodeTypes.php | 54 + lib/RoadizCoreBundle/src/Bag/Roles.php | 75 + lib/RoadizCoreBundle/src/Bag/Settings.php | 81 + .../src/Cache/Clearer/AssetsFileClearer.php | 27 + .../src/Cache/Clearer/ClearerInterface.php | 23 + .../src/Cache/Clearer/FileClearer.php | 36 + .../Clearer/NodesSourcesUrlsCacheClearer.php | 29 + .../src/Cache/Clearer/OPCacheClearer.php | 40 + .../src/Cache/CloudflareProxyCache.php | 92 + .../src/Cache/ReverseProxyCache.php | 51 + .../src/Cache/ReverseProxyCacheLocator.php | 40 + .../CollectionFieldConfiguration.php | 28 + .../JoinNodeTypeFieldConfiguration.php | 84 + .../ProviderFieldConfiguration.php | 45 + .../src/Console/CleanLoginAttemptCommand.php | 43 + .../Console/CustomFormAnswerPurgeCommand.php | 124 + .../src/Console/DecodePrivateKeyCommand.php | 49 + .../src/Console/EncodePrivateKeyCommand.php | 50 + .../src/Console/FilesCommandTrait.php | 32 + .../src/Console/FilesExportCommand.php | 116 + .../src/Console/FilesImportCommand.php | 97 + .../Console/GenerateApiResourceCommand.php | 58 + .../GenerateNodeSourceEntitiesCommand.php | 68 + .../src/Console/GeneratePrivateKeyCommand.php | 47 + .../src/Console/InstallCommand.php | 136 + .../src/Console/LogsCleanupCommand.php | 84 + .../src/Console/MailerTestCommand.php | 61 + .../NodeApplyUniversalFieldsCommand.php | 89 + .../src/Console/NodeClearTagCommand.php | 103 + .../src/Console/NodeTypesAddFieldCommand.php | 60 + .../src/Console/NodeTypesCommand.php | 100 + .../src/Console/NodeTypesCreationCommand.php | 164 + .../src/Console/NodeTypesDeleteCommand.php | 95 + .../src/Console/NodesCleanNamesCommand.php | 158 + .../src/Console/NodesCommand.php | 81 + .../src/Console/NodesCreationCommand.php | 133 + .../src/Console/NodesDetailsCommand.php | 89 + .../src/Console/NodesEmptyTrashCommand.php | 100 + .../src/Console/NodesOrphansCommand.php | 102 + .../src/Console/PrivateKeyCommand.php | 57 + .../src/Console/PurgeLoginAttemptCommand.php | 54 + .../src/Console/SolrCommand.php | 75 + .../src/Console/SolrOptimizeCommand.php | 61 + .../src/Console/SolrReindexCommand.php | 153 + .../src/Console/SolrResetCommand.php | 68 + .../Console/ThemeAwareCommandInterface.php | 9 + .../src/Console/TranslationsCommand.php | 61 + .../Console/TranslationsCreationCommand.php | 92 + .../src/Console/TranslationsDeleteCommand.php | 83 + .../Console/TranslationsDisableCommand.php | 72 + .../src/Console/TranslationsEnableCommand.php | 72 + .../src/Console/UsersCommand.php | 124 + .../src/Console/UsersCreationCommand.php | 161 + .../src/Console/UsersDeleteCommand.php | 63 + .../src/Console/UsersDisableCommand.php | 63 + .../src/Console/UsersEnableCommand.php | 62 + .../src/Console/UsersPasswordCommand.php | 77 + .../src/Console/UsersRolesCommand.php | 110 + .../src/Console/VersionsPurgeCommand.php | 151 + .../src/Controller/CustomFormController.php | 445 + .../DefaultNodeSourceController.php | 19 + .../src/Controller/HealthCheckController.php | 52 + .../src/Controller/RedirectionController.php | 98 + .../src/Crypto/UniqueKeyEncoderFactory.php | 55 + .../CustomForm/CustomFormAnswerSerializer.php | 50 + .../src/CustomForm/CustomFormHelper.php | 240 + .../CustomForm/CustomFormHelperFactory.php | 54 + .../Compiler/CommonMarkCompilerPass.php | 63 + .../DoctrineMigrationCompilerPass.php | 55 + .../Compiler/DocumentRendererCompilerPass.php | 35 + .../Compiler/FlysystemStorageCompilerPass.php | 93 + .../Compiler/ImporterCompilerPass.php | 32 + .../Compiler/MediaFinderCompilerPass.php | 33 + .../Compiler/NodeWorkflowCompilerPass.php | 30 + .../NodesSourcesEntitiesPathCompilerPass.php | 22 + .../Compiler/PathResolverCompilerPass.php | 35 + .../Compiler/RateLimitersCompilerPass.php | 46 + .../Compiler/TwigLoaderCompilerPass.php | 28 + .../src/DependencyInjection/Configuration.php | 218 + .../RoadizCoreExtension.php | 387 + ...rNodesSourcesQueryBuilderCriteriaEvent.php | 40 + .../Event/FilterQueryBuilderCriteriaEvent.php | 95 + .../Event/FilterQueryBuilderEvent.php | 61 + .../Event/FilterQueryCriteriaEvent.php | 88 + .../QueryBuilder/QueryBuilderApplyEvent.php | 11 + .../QueryBuilder/QueryBuilderBuildEvent.php | 11 + .../QueryBuilderNodesSourcesApplyEvent.php | 11 + .../QueryBuilderNodesSourcesBuildEvent.php | 11 + .../QueryBuilder/QueryBuilderSelectEvent.php | 11 + .../src/Doctrine/Event/QueryEvent.php | 44 + .../Doctrine/Event/QueryNodesSourcesEvent.php | 52 + .../AttributeValueLifeCycleSubscriber.php | 93 + .../CustomFormFieldLifeCycleSubscriber.php | 63 + .../NodesSourcesInheritanceSubscriber.php | 126 + .../SettingLifeCycleSubscriber.php | 123 + .../EventSubscriber/TablePrefixSubscriber.php | 61 + .../UserLifeCycleSubscriber.php | 210 + .../Loggable/UserLoggableListener.php | 57 + .../src/Doctrine/ORM/Filter/ANodesFilter.php | 158 + .../src/Doctrine/ORM/Filter/BNodesFilter.php | 46 + .../ORM/Filter/NodeTranslationFilter.php | 118 + .../Doctrine/ORM/Filter/NodeTypeFilter.php | 109 + .../ORM/Filter/NodesSourcesNodeFilter.php | 65 + .../ORM/Filter/NodesSourcesNodeTypeFilter.php | 92 + .../Filter/NodesSourcesReachableFilter.php | 99 + .../src/Doctrine/ORM/SimpleQueryBuilder.php | 237 + .../src/Doctrine/SchemaUpdater.php | 132 + .../src/Document/DocumentFactory.php | 46 + .../src/Document/DocumentFinder.php | 65 + .../DocumentMessageDispatchSubscriber.php | 64 + .../MediaFinder/DailymotionEmbedFinder.php | 15 + .../MediaFinder/DeezerEmbedFinder.php | 12 + .../Document/MediaFinder/EmbedFinderTrait.php | 67 + .../MediaFinder/MixcloudEmbedFinder.php | 12 + .../Document/MediaFinder/PodcastFinder.php | 41 + .../MediaFinder/SoundcloudEmbedFinder.php | 15 + .../MediaFinder/SpotifyEmbedFinder.php | 12 + .../Document/MediaFinder/TedEmbedFinder.php | 12 + .../MediaFinder/TwitchEmbedFinder.php | 12 + .../MediaFinder/UnsplashPictureFinder.php | 12 + .../Document/MediaFinder/VimeoEmbedFinder.php | 15 + .../MediaFinder/YoutubeEmbedFinder.php | 15 + .../Message/AbstractDocumentMessage.php | 28 + .../Message/DocumentAudioVideoMessage.php | 9 + .../Message/DocumentAverageColorMessage.php | 9 + .../Document/Message/DocumentExifMessage.php | 9 + .../Message/DocumentFilesizeMessage.php | 9 + .../Document/Message/DocumentRawMessage.php | 9 + .../Document/Message/DocumentSizeMessage.php | 9 + .../Document/Message/DocumentSvgMessage.php | 9 + .../AbstractDocumentMessageHandler.php | 56 + .../AbstractLockingDocumentMessageHandler.php | 52 + .../DocumentAudioVideoMessageHandler.php | 145 + .../DocumentAverageColorMessageHandler.php | 65 + .../DocumentExifMessageHandler.php | 123 + .../DocumentFilesizeMessageHandler.php | 34 + .../DocumentRawMessageHandler.php | 41 + .../DocumentSizeMessageHandler.php | 58 + .../DocumentSvgMessageHandler.php | 54 + .../src/Document/PrivateDocumentFactory.php | 48 + lib/RoadizCoreBundle/src/Entity/Attribute.php | 97 + .../src/Entity/AttributeDocuments.php | 122 + .../src/Entity/AttributeGroup.php | 37 + .../src/Entity/AttributeGroupTranslation.php | 27 + .../src/Entity/AttributeTranslation.php | 25 + .../src/Entity/AttributeValue.php | 129 + .../src/Entity/AttributeValueTranslation.php | 23 + .../src/Entity/CustomForm.php | 457 + .../src/Entity/CustomFormAnswer.php | 202 + .../src/Entity/CustomFormField.php | 169 + .../src/Entity/CustomFormFieldAttribute.php | 159 + lib/RoadizCoreBundle/src/Entity/Document.php | 802 ++ .../src/Entity/DocumentTranslation.php | 173 + lib/RoadizCoreBundle/src/Entity/Folder.php | 349 + .../src/Entity/FolderTranslation.php | 116 + lib/RoadizCoreBundle/src/Entity/Group.php | 187 + lib/RoadizCoreBundle/src/Entity/Log.php | 244 + .../src/Entity/LoginAttempt.php | 106 + lib/RoadizCoreBundle/src/Entity/Node.php | 1003 ++ lib/RoadizCoreBundle/src/Entity/NodeType.php | 522 + .../src/Entity/NodeTypeField.php | 418 + .../src/Entity/NodesCustomForms.php | 141 + .../src/Entity/NodesSources.php | 672 + .../src/Entity/NodesSourcesDocuments.php | 152 + lib/RoadizCoreBundle/src/Entity/NodesTags.php | 100 + .../src/Entity/NodesToNodes.php | 140 + lib/RoadizCoreBundle/src/Entity/Realm.php | 284 + lib/RoadizCoreBundle/src/Entity/RealmNode.php | 109 + .../src/Entity/Redirection.php | 143 + lib/RoadizCoreBundle/src/Entity/Role.php | 230 + lib/RoadizCoreBundle/src/Entity/Setting.php | 340 + .../src/Entity/SettingGroup.php | 124 + lib/RoadizCoreBundle/src/Entity/Tag.php | 449 + .../src/Entity/TagTranslation.php | 233 + .../src/Entity/TagTranslationDocuments.php | 105 + lib/RoadizCoreBundle/src/Entity/Theme.php | 177 + .../src/Entity/Translation.php | 827 ++ lib/RoadizCoreBundle/src/Entity/UrlAlias.php | 85 + lib/RoadizCoreBundle/src/Entity/User.php | 1067 ++ .../src/Entity/UserLogEntry.php | 50 + lib/RoadizCoreBundle/src/Entity/Webhook.php | 272 + .../src/EntityApi/AbstractApi.php | 54 + .../src/EntityApi/NodeApi.php | 84 + .../src/EntityApi/NodeSourceApi.php | 141 + .../src/EntityApi/NodeTypeApi.php | 40 + lib/RoadizCoreBundle/src/EntityApi/TagApi.php | 77 + .../EntityHandler/CustomFormFieldHandler.php | 67 + .../src/EntityHandler/CustomFormHandler.php | 62 + .../src/EntityHandler/DocumentHandler.php | 101 + .../src/EntityHandler/FolderHandler.php | 190 + .../src/EntityHandler/GroupHandler.php | 58 + .../src/EntityHandler/HandlerFactory.php | 70 + .../src/EntityHandler/NodeHandler.php | 682 + .../EntityHandler/NodeTypeFieldHandler.php | 68 + .../src/EntityHandler/NodeTypeHandler.php | 380 + .../src/EntityHandler/NodesSourcesHandler.php | 540 + .../src/EntityHandler/TagHandler.php | 280 + .../src/EntityHandler/TranslationHandler.php | 74 + .../Event/Cache/CachePurgeRequestEvent.php | 11 + .../DocumentTranslationIndexingEvent.php | 87 + .../DocumentTranslationUpdatedEvent.php | 28 + .../src/Event/FilterCacheEvent.php | 68 + .../src/Event/FilterFolderEvent.php | 26 + .../src/Event/FilterNodeEvent.php | 26 + .../src/Event/FilterNodePathEvent.php | 44 + .../src/Event/FilterNodesSourcesEvent.php | 26 + .../src/Event/FilterSettingEvent.php | 23 + .../src/Event/FilterTagEvent.php | 29 + .../src/Event/FilterTranslationEvent.php | 26 + .../src/Event/FilterUrlAliasEvent.php | 26 + .../src/Event/FilterUserEvent.php | 26 + .../src/Event/Folder/FolderCreatedEvent.php | 11 + .../src/Event/Folder/FolderDeletedEvent.php | 11 + .../src/Event/Folder/FolderUpdatedEvent.php | 11 + .../src/Event/Node/NodeCreatedEvent.php | 11 + .../src/Event/Node/NodeDeletedEvent.php | 11 + .../src/Event/Node/NodeDuplicatedEvent.php | 11 + .../src/Event/Node/NodePathChangedEvent.php | 11 + .../src/Event/Node/NodeTaggedEvent.php | 11 + .../src/Event/Node/NodeUndeletedEvent.php | 11 + .../src/Event/Node/NodeUpdatedEvent.php | 11 + .../Event/Node/NodeVisibilityChangedEvent.php | 11 + .../NodesSources/NodesSourcesCreatedEvent.php | 11 + .../NodesSources/NodesSourcesDeletedEvent.php | 11 + .../NodesSourcesIndexingEvent.php | 90 + .../NodesSourcesPathGeneratingEvent.php | 220 + .../NodesSourcesPreUpdatedEvent.php | 11 + .../NodesSources/NodesSourcesUpdatedEvent.php | 11 + .../Event/Realm/AbstractRealmNodeEvent.php | 28 + .../src/Event/Realm/NodeJoinedRealmEvent.php | 9 + .../src/Event/Realm/NodeLeftRealmEvent.php | 9 + .../src/Event/Role/PreCreatedRoleEvent.php | 9 + .../src/Event/Role/PreDeletedRoleEvent.php | 9 + .../src/Event/Role/PreUpdatedRoleEvent.php | 9 + .../src/Event/Role/RoleEvent.php | 42 + .../src/Event/Setting/SettingCreatedEvent.php | 11 + .../src/Event/Setting/SettingDeletedEvent.php | 11 + .../src/Event/Setting/SettingUpdatedEvent.php | 11 + .../src/Event/Tag/TagCreatedEvent.php | 11 + .../src/Event/Tag/TagDeletedEvent.php | 11 + .../src/Event/Tag/TagUpdatedEvent.php | 11 + .../Translation/TranslationCreatedEvent.php | 11 + .../Translation/TranslationDeletedEvent.php | 11 + .../Translation/TranslationUpdatedEvent.php | 11 + .../Event/UrlAlias/UrlAliasCreatedEvent.php | 11 + .../Event/UrlAlias/UrlAliasDeletedEvent.php | 11 + .../Event/UrlAlias/UrlAliasUpdatedEvent.php | 11 + .../src/Event/User/UserCreatedEvent.php | 11 + .../src/Event/User/UserDeletedEvent.php | 11 + .../src/Event/User/UserDisabledEvent.php | 11 + .../src/Event/User/UserEnabledEvent.php | 11 + .../src/Event/User/UserJoinedGroupEvent.php | 28 + .../src/Event/User/UserLeavedGroupEvent.php | 28 + .../Event/User/UserPasswordChangedEvent.php | 11 + .../src/Event/User/UserUpdatedEvent.php | 11 + .../AssetsCacheEventSubscriber.php | 43 + .../AttributeValueIndexingSubscriber.php | 90 + .../AutomaticWebhookSubscriber.php | 149 + .../CloudflareCacheEventSubscriber.php | 217 + .../DocumentTimestampSubscriber.php | 30 + .../src/EventSubscriber/LocaleSubscriber.php | 70 + .../LoggableUsernameSubscriber.php | 59 + .../NodeDuplicationSubscriber.php | 46 + .../EventSubscriber/NodeNameSubscriber.php | 108 + .../NodeRedirectionSubscriber.php | 71 + .../NodeSourcePathSubscriber.php | 49 + .../NodesSourcesLinkHeaderEventSubscriber.php | 72 + .../NodesSourcesUniversalSubscriber.php | 57 + .../NodesSourcesUrlsCacheEventSubscriber.php | 82 + .../OPCacheEventSubscriber.php | 38 + .../RealmNodeInheritanceSubscriber.php | 76 + .../ReverseProxyCacheEventSubscriber.php | 156 + .../src/EventSubscriber/RoleSubscriber.php | 61 + .../EventSubscriber/SignatureSubscriber.php | 48 + .../TagTimestampSubscriber.php | 30 + .../EventSubscriber/TranslationSubscriber.php | 57 + .../EventSubscriber/UserLocaleSubscriber.php | 76 + .../src/Exception/BadFormRequestException.php | 38 + .../EmbedPlatformNotSupportedException.php | 12 + .../src/Exception/EmptySaltException.php | 12 + .../EntityAlreadyExistsException.php | 13 + .../src/Exception/EntityRequiredException.php | 12 + .../src/Exception/ExceptionViewer.php | 326 + .../FacebookUsernameNotFoundException.php | 12 + .../src/Exception/ForceResponseException.php | 45 + .../Exception/MaintenanceModeException.php | 41 + .../NoConfigurationFoundException.php | 18 + .../NoTranslationAvailableException.php | 18 + .../NoYamlConfigurationFoundException.php | 16 + .../Exception/ReservedSQLWordException.php | 17 + .../SolrServerNotAvailableException.php | 12 + .../SolrServerNotConfiguredException.php | 12 + .../Exception/ThemeClassNotValidException.php | 13 + .../TooManyLoginAttemptsException.php | 18 + .../AbstractDoctrineExplorerProvider.php | 123 + .../src/Explorer/AbstractExplorerItem.php | 32 + .../src/Explorer/AbstractExplorerProvider.php | 32 + .../src/Explorer/ExplorerItemInterface.php | 37 + .../Explorer/ExplorerProviderInterface.php | 40 + .../src/Filesystem/RoadizFileDirectories.php | 60 + .../src/Form/AttributeChoiceType.php | 76 + .../src/Form/AttributeDocumentType.php | 100 + .../Form/AttributeGroupTranslationType.php | 66 + .../src/Form/AttributeGroupType.php | 46 + .../src/Form/AttributeGroupsType.php | 68 + .../src/Form/AttributeImportType.php | 29 + .../src/Form/AttributeTranslationType.php | 87 + .../src/Form/AttributeType.php | 104 + .../Form/AttributeValueTranslationType.php | 144 + .../src/Form/AttributeValueType.php | 47 + lib/RoadizCoreBundle/src/Form/ColorType.php | 43 + .../src/Form/CompareDateType.php | 52 + .../src/Form/CompareDatetimeType.php | 69 + .../src/Form/Constraint/HexadecimalColor.php | 17 + .../Constraint/HexadecimalColorValidator.php | 20 + .../src/Form/Constraint/NodeTypeField.php | 25 + .../Constraint/NodeTypeFieldValidator.php | 222 + .../Form/Constraint/NonSqlReservedWord.php | 72 + .../NonSqlReservedWordValidator.php | 29 + .../src/Form/Constraint/Recaptcha.php | 30 + .../Constraint/RecaptchaServiceInterface.php | 21 + .../Form/Constraint/RecaptchaValidator.php | 106 + .../src/Form/Constraint/SimpleLatinString.php | 17 + .../Constraint/SimpleLatinStringValidator.php | 20 + .../src/Form/Constraint/UniqueEntity.php | 42 + .../Form/Constraint/UniqueEntityValidator.php | 171 + .../src/Form/Constraint/UniqueFilename.php | 18 + .../Constraint/UniqueFilenameValidator.php | 48 + .../src/Form/Constraint/UniqueNodeName.php | 19 + .../Constraint/UniqueNodeNameValidator.php | 77 + .../src/Form/Constraint/UniqueTagName.php | 13 + .../Constraint/UniqueTagNameValidator.php | 85 + .../ValidAccountConfirmationToken.php | 21 + ...ValidAccountConfirmationTokenValidator.php | 41 + .../src/Form/Constraint/ValidAccountEmail.php | 12 + .../Constraint/ValidAccountEmailValidator.php | 42 + .../src/Form/Constraint/ValidFacebookName.php | 17 + .../Constraint/ValidFacebookNameValidator.php | 37 + .../src/Form/Constraint/ValidJson.php | 12 + .../Form/Constraint/ValidJsonValidator.php | 32 + .../src/Form/Constraint/ValidYaml.php | 12 + .../Form/Constraint/ValidYamlValidator.php | 35 + .../src/Form/CreatePasswordType.php | 50 + lib/RoadizCoreBundle/src/Form/CssType.php | 49 + .../src/Form/CustomFormsType.php | 327 + .../AttributeDocumentsTransformer.php | 84 + .../AttributeGroupTransformer.php | 67 + .../DocumentCollectionTransformer.php | 23 + .../EntityCollectionTransformer.php | 99 + .../ExplorerProviderItemTransformer.php | 96 + .../FolderCollectionTransformer.php | 23 + .../DataTransformer/JoinDataTransformer.php | 96 + .../DataTransformer/NodeTypeTransformer.php | 60 + .../PersistableTransformer.php | 54 + .../ProviderDataTransformer.php | 65 + .../ReversePersistableTransformer.php | 57 + .../TagTranslationDocumentsTransformer.php | 88 + .../TranslationTransformer.php | 62 + .../src/Form/DocumentCollectionType.php | 67 + .../src/Form/EnumerationType.php | 67 + .../src/Form/Error/FormErrorSerializer.php | 59 + .../Error/FormErrorSerializerInterface.php | 12 + .../src/Form/ExplorerProviderItemType.php | 95 + .../src/Form/ExtendedBooleanType.php | 45 + .../Form/Extension/HelpAndGroupExtension.php | 36 + lib/RoadizCoreBundle/src/Form/GroupsType.php | 100 + .../src/Form/HoneypotType.php | 68 + lib/RoadizCoreBundle/src/Form/JsonType.php | 53 + .../src/Form/LoginRequestForm.php | 36 + .../src/Form/LoginResetForm.php | 58 + .../src/Form/MarkdownType.php | 116 + .../src/Form/MultipleEnumerationType.php | 66 + .../src/Form/NodeStatesType.php | 48 + .../src/Form/NodeTypesType.php | 68 + lib/RoadizCoreBundle/src/Form/NodesType.php | 112 + .../src/Form/RealmChoiceType.php | 57 + .../src/Form/RealmNodeType.php | 39 + lib/RoadizCoreBundle/src/Form/RealmType.php | 74 + .../src/Form/RecaptchaType.php | 65 + .../src/Form/RoleEntityType.php | 69 + lib/RoadizCoreBundle/src/Form/RolesType.php | 82 + .../src/Form/SeparatorType.php | 31 + .../src/Form/SettingDocumentType.php | 82 + .../src/Form/SettingGroupType.php | 90 + lib/RoadizCoreBundle/src/Form/SettingType.php | 179 + .../src/Form/SettingTypeResolver.php | 51 + .../src/Form/TagTranslationDocumentType.php | 101 + lib/RoadizCoreBundle/src/Form/TagsType.php | 77 + lib/RoadizCoreBundle/src/Form/ThemesType.php | 52 + .../src/Form/TranslationsType.php | 65 + .../src/Form/UrlAliasType.php | 58 + .../src/Form/UserCollectionType.php | 64 + lib/RoadizCoreBundle/src/Form/UsersType.php | 70 + lib/RoadizCoreBundle/src/Form/WebhookType.php | 70 + .../src/Form/WebhooksChoiceType.php | 62 + lib/RoadizCoreBundle/src/Form/YamlType.php | 54 + .../src/Importer/AttributeImporter.php | 48 + .../src/Importer/ChainImporter.php | 65 + .../src/Importer/EntityImporterInterface.php | 22 + .../src/Importer/GroupsImporter.php | 47 + .../src/Importer/NodeTypesImporter.php | 57 + .../src/Importer/RolesImporter.php | 48 + .../src/Importer/SettingsImporter.php | 63 + .../src/Importer/TagsImporter.php | 50 + .../ListManager/AbstractEntityListManager.php | 216 + .../src/ListManager/EntityListManager.php | 278 + .../EntityListManagerInterface.php | 117 + .../src/ListManager/NodePaginator.php | 95 + .../src/ListManager/NodesSourcesPaginator.php | 51 + .../src/ListManager/Paginator.php | 277 + .../ListManager/QueryBuilderListManager.php | 148 + .../src/ListManager/TagListManager.php | 50 + .../src/Logger/DoctrineHandler.php | 143 + .../src/Mailer/ContactFormManager.php | 729 ++ .../src/Mailer/EmailManager.php | 610 + .../ApplyRealmNodeInheritanceMessage.php | 33 + .../src/Message/AsyncMessage.php | 9 + .../CleanRealmNodeInheritanceMessage.php | 33 + .../src/Message/DeleteNodeTypeMessage.php | 26 + .../src/Message/GuzzleRequestMessage.php | 42 + ...pplyRealmNodeInheritanceMessageHandler.php | 85 + ...leanRealmNodeInheritanceMessageHandler.php | 60 + .../Handler/DeleteNodeTypeMessageHandler.php | 54 + .../Handler/HttpRequestMessageHandler.php | 43 + .../PurgeReverseProxyCacheMessageHandler.php | 108 + ...archRealmNodeInheritanceMessageHandler.php | 98 + .../UpdateDoctrineSchemaMessageHandler.php | 28 + .../UpdateNodeTypeSchemaMessageHandler.php | 48 + .../src/Message/HttpRequestMessage.php | 13 + .../Message/PurgeReverseProxyCacheMessage.php | 26 + .../SearchRealmNodeInheritanceMessage.php | 23 + .../Message/UpdateDoctrineSchemaMessage.php | 9 + .../Message/UpdateNodeTypeSchemaMessage.php | 26 + .../src/Model/AttributableInterface.php | 52 + .../src/Model/AttributableTrait.php | 100 + .../src/Model/AttributeGroupInterface.php | 25 + .../src/Model/AttributeGroupTrait.php | 164 + .../AttributeGroupTranslationInterface.php | 47 + .../Model/AttributeGroupTranslationTrait.php | 92 + .../src/Model/AttributeInterface.php | 218 + .../src/Model/AttributeTrait.php | 347 + .../Model/AttributeTranslationInterface.php | 60 + .../src/Model/AttributeTranslationTrait.php | 119 + .../src/Model/AttributeValueInterface.php | 61 + .../src/Model/AttributeValueTrait.php | 118 + .../AttributeValueTranslationInterface.php | 52 + .../Model/AttributeValueTranslationTrait.php | 136 + .../src/Model/RealmInterface.php | 51 + .../Node/Exception/SameNodeUrlException.php | 9 + .../src/Node/NodeDuplicator.php | 149 + lib/RoadizCoreBundle/src/Node/NodeFactory.php | 113 + lib/RoadizCoreBundle/src/Node/NodeMover.php | 226 + .../src/Node/NodeNameChecker.php | 129 + .../src/Node/NodeNamePolicyFactory.php | 31 + .../src/Node/NodeNamePolicyInterface.php | 34 + .../src/Node/NodeTranslator.php | 90 + .../src/Node/NodeTranstyper.php | 306 + .../src/Node/UniqueNodeGenerator.php | 162 + .../src/Node/UniversalDataDuplicator.php | 171 + .../src/NodeType/ApiResourceGenerator.php | 173 + .../src/NodeType/NodeTypeResolver.php | 63 + .../EventSubscriber/PreviewBarSubscriber.php | 81 + .../EventSubscriber/PreviewModeSubscriber.php | 108 + .../Exception/PreviewNotAllowedException.php | 19 + .../src/Preview/PreviewResolverInterface.php | 11 + .../src/Preview/RequestPreviewRevolver.php | 43 + .../src/Preview/User/PreviewUser.php | 68 + .../src/Preview/User/PreviewUserProvider.php | 38 + .../User/PreviewUserProviderInterface.php | 17 + .../src/Realm/RealmResolver.php | 48 + .../src/Realm/RealmResolverInterface.php | 26 + .../AttributeDocumentsRepository.php | 40 + .../Repository/AttributeGroupRepository.php | 20 + .../AttributeGroupTranslationRepository.php | 36 + .../src/Repository/AttributeRepository.php | 20 + .../AttributeTranslationRepository.php | 20 + .../Repository/AttributeValueRepository.php | 91 + .../AttributeValueTranslationRepository.php | 20 + .../Repository/CustomFormAnswerRepository.php | 58 + .../CustomFormFieldAttributeRepository.php | 20 + .../Repository/CustomFormFieldRepository.php | 20 + .../src/Repository/CustomFormRepository.php | 48 + .../src/Repository/DocumentRepository.php | 681 + .../DocumentTranslationRepository.php | 40 + .../src/Repository/EntityRepository.php | 525 + .../src/Repository/FolderRepository.php | 305 + .../FolderTranslationRepository.php | 20 + .../src/Repository/GroupRepository.php | 25 + .../src/Repository/LogRepository.php | 76 + .../src/Repository/LoginAttemptRepository.php | 139 + .../src/Repository/NodeRepository.php | 1116 ++ .../Repository/NodeTypeFieldRepository.php | 109 + .../src/Repository/NodeTypeRepository.php | 36 + .../Repository/NodesCustomFormsRepository.php | 41 + .../NodesSourcesDocumentsRepository.php | 44 + .../src/Repository/NodesSourcesRepository.php | 755 ++ .../src/Repository/NodesToNodesRepository.php | 46 + .../src/Repository/PrefixAwareRepository.php | 340 + .../src/Repository/RealmNodeRepository.php | 35 + .../src/Repository/RealmRepository.php | 44 + .../src/Repository/RedirectionRepository.php | 25 + .../src/Repository/RoleRepository.php | 110 + .../src/Repository/SettingGroupRepository.php | 53 + .../src/Repository/SettingRepository.php | 84 + .../src/Repository/StatusAwareRepository.php | 113 + .../src/Repository/TagRepository.php | 770 ++ .../TagTranslationDocumentsRepository.php | 44 + .../Repository/TagTranslationRepository.php | 25 + .../src/Repository/TranslationRepository.php | 455 + .../src/Repository/UrlAliasRepository.php | 61 + .../src/Repository/UserLogEntryRepository.php | 25 + .../src/Repository/UserRepository.php | 62 + .../src/Repository/WebhookRepository.php | 25 + lib/RoadizCoreBundle/src/RoadizCoreBundle.php | 44 + .../src/Routing/ChainResourcePathResolver.php | 44 + .../src/Routing/DeferredRouteCollection.php | 29 + .../src/Routing/DocumentUrlGenerator.php | 62 + .../src/Routing/DynamicUrlMatcher.php | 42 + .../src/Routing/InstallRouteCollection.php | 36 + .../src/Routing/NodePathInfo.php | 136 + .../src/Routing/NodeRouteHelper.php | 141 + .../src/Routing/NodeRouter.php | 307 + .../src/Routing/NodeUrlMatcher.php | 128 + .../src/Routing/NodeUrlMatcherInterface.php | 21 + .../Routing/NodesSourcesPathAggregator.php | 12 + .../src/Routing/NodesSourcesPathResolver.php | 265 + .../src/Routing/NodesSourcesUrlGenerator.php | 151 + .../src/Routing/NullLoader.php | 59 + ...timizedNodesSourcesGraphPathAggregator.php | 129 + .../src/Routing/PathResolverInterface.php | 24 + .../src/Routing/RedirectableUrlMatcher.php | 33 + .../src/Routing/RedirectionMatcher.php | 79 + .../src/Routing/RedirectionPathResolver.php | 37 + .../src/Routing/RedirectionRouter.php | 82 + .../src/Routing/ResourceInfo.php | 88 + .../src/Routing/RouteHandler.php | 22 + .../src/Routing/StaticRouter.php | 52 + .../SearchEngine/AbstractSearchHandler.php | 381 + .../src/SearchEngine/AbstractSolarium.php | 313 + .../src/SearchEngine/ClientRegistry.php | 43 + .../SearchEngine/DocumentSearchHandler.php | 137 + .../GlobalNodeSourceSearchHandler.php | 107 + .../SearchEngine/Indexer/AbstractIndexer.php | 93 + .../src/SearchEngine/Indexer/BatchIndexer.php | 10 + .../SearchEngine/Indexer/CliAwareIndexer.php | 12 + .../SearchEngine/Indexer/DocumentIndexer.php | 90 + .../SearchEngine/Indexer/FolderIndexer.php | 52 + .../src/SearchEngine/Indexer/Indexer.php | 14 + .../SearchEngine/Indexer/IndexerFactory.php | 43 + .../Indexer/IndexerFactoryInterface.php | 14 + .../src/SearchEngine/Indexer/NodeIndexer.php | 38 + .../Indexer/NodesSourcesIndexer.php | 144 + .../src/SearchEngine/Indexer/TagIndexer.php | 49 + .../Message/AbstractSolrMessage.php | 41 + .../Handler/SolrDeleteMessageHandler.php | 36 + .../Handler/SolrReindexMessageHandler.php | 36 + .../Message/SolrDeleteMessage.php | 9 + .../Message/SolrReindexMessage.php | 9 + .../SearchEngine/NodeSourceSearchHandler.php | 271 + .../NodeSourceSearchHandlerInterface.php | 12 + .../SearchEngine/SearchHandlerInterface.php | 54 + .../SearchEngine/SearchResultsInterface.php | 12 + .../src/SearchEngine/SolariumDocument.php | 206 + .../SolariumDocumentTranslation.php | 68 + .../src/SearchEngine/SolariumFactory.php | 69 + .../SearchEngine/SolariumFactoryInterface.php | 16 + .../src/SearchEngine/SolariumNodeSource.php | 73 + .../src/SearchEngine/SolrSearchResults.php | 224 + ...tDocumentTranslationIndexingSubscriber.php | 130 + .../DefaultNodesSourcesIndexingSubscriber.php | 188 + .../Subscriber/SolariumSubscriber.php | 178 + .../JwtAuthenticationSuccessHandler.php | 39 + .../Manager/LoginAttemptManager.php | 160 + .../Authentication/RoadizAuthenticator.php | 139 + .../Authorization/AccessDeniedHandler.php | 85 + .../Chroot/NodeChrootChainResolver.php | 68 + .../Chroot/NodeChrootResolver.php | 33 + .../Chroot/RoadizUserNodeChrootResolver.php | 31 + .../Authorization/Voter/GroupVoter.php | 106 + .../Authorization/Voter/RealmVoter.php | 103 + .../Authorization/Voter/RoleArrayVoter.php | 53 + .../Voter/SuperAdminRoleHierarchyVoter.php | 45 + .../src/Security/Blacklist/Top500Provider.php | 522 + .../Security/User/AdvancedUserInterface.php | 13 + .../src/Security/User/UserProvider.php | 108 + .../src/Security/User/UserViewer.php | 150 + .../Serializer/CircularReferenceHandler.php | 36 + .../Normalizer/AbstractPathNormalizer.php | 61 + .../Normalizer/AttributeValueNormalizer.php | 53 + .../Normalizer/CustomFormNormalizer.php | 47 + .../Normalizer/DocumentNormalizer.php | 109 + .../Normalizer/DocumentSourcesNormalizer.php | 65 + .../Normalizer/FolderNormalizer.php | 36 + .../Normalizer/NodesSourcesPathNormalizer.php | 41 + .../RealmSerializationGroupNormalizer.php | 77 + .../Serializer/Normalizer/TagNormalizer.php | 45 + .../Normalizer/TranslationAwareNormalizer.php | 108 + .../AbstractTypedObjectConstructor.php | 100 + .../ChainDoctrineObjectConstructor.php | 131 + .../GroupObjectConstructor.php | 44 + .../NodeObjectConstructor.php | 43 + .../NodeTypeFieldObjectConstructor.php | 64 + .../NodeTypeObjectConstructor.php | 40 + .../ObjectConstructor/ObjectConstructor.php | 24 + .../RoleObjectConstructor.php | 40 + .../SettingGroupObjectConstructor.php | 40 + .../SettingObjectConstructor.php | 38 + .../TagObjectConstructor.php | 56 + .../TranslationObjectConstructor.php | 42 + .../TypedObjectConstructorInterface.php | 22 + .../TranslationAwareContextBuilder.php | 57 + lib/RoadizCoreBundle/src/Tag/TagFactory.php | 79 + .../src/Traits/LoginRequestTrait.php | 72 + .../src/Traits/LoginResetTrait.php | 51 + .../src/Translation/TranslationViewer.php | 250 + .../src/TwigExtension/AttributesExtension.php | 266 + .../TwigExtension/BlockRenderExtension.php | 83 + .../CentralTruncateExtension.php | 50 + .../TwigExtension/DocumentUrlExtension.php | 84 + .../src/TwigExtension/HandlerExtension.php | 54 + .../src/TwigExtension/JwtExtension.php | 51 + .../TwigExtension/NodesSourcesExtension.php | 251 + .../src/TwigExtension/RoadizExtension.php | 68 + .../src/TwigExtension/RoutingExtension.php | 101 + .../TokenParser/TransChoiceTokenParser.php | 89 + .../TwigExtension/TransChoiceExtension.php | 62 + .../TwigExtension/TranslationExtension.php | 70 + .../TranslationMenuExtension.php | 52 + .../TooManyWebhookTriggeredException.php | 30 + .../Message/GenericJsonPostMessage.php | 61 + .../Message/GitlabPipelineTriggerMessage.php | 80 + .../Message/NetlifyBuildHookMessage.php | 63 + .../src/Webhook/Message/WebhookMessage.php | 16 + .../Webhook/Message/WebhookMessageFactory.php | 30 + .../WebhookMessageFactoryInterface.php | 13 + .../Webhook/ThrottledWebhookDispatcher.php | 53 + .../src/Webhook/WebhookDispatcher.php | 10 + .../src/Webhook/WebhookInterface.php | 25 + .../Event/NodeStatusGuardListener.php | 93 + .../src/Workflow/NodeWorkflow.php | 52 + .../src/Xlsx/AbstractXlsxSerializer.php | 48 + .../src/Xlsx/NodeSourceXlsxSerializer.php | 180 + .../src/Xlsx/SerializerInterface.php | 39 + .../src/Xlsx/XlsxExporter.php | 139 + .../SwaggerUi/index.html.twig | 130 + lib/RoadizCoreBundle/templates/base.html.twig | 14 + .../customForm/base_custom_form.html.twig | 26 + .../templates/customForm/customForm.html.twig | 28 + .../customForm/customFormSent.html.twig | 16 + .../customForm/customForms.html.twig | 144 + .../templates/email/404.html.twig | 157 + .../templates/email/base_email.html.twig | 60 + .../templates/email/base_email.txt.twig | 18 + .../email/forms/answerForm.html.twig | 42 + .../templates/email/forms/answerForm.txt.twig | 13 + .../email/forms/contactForm.html.twig | 46 + .../email/forms/contactForm.txt.twig | 17 + .../users/reset_password_email.html.twig | 56 + .../email/users/reset_password_email.txt.twig | 24 + .../email/users/welcome_user_email.html.twig | 14 + .../email/users/welcome_user_email.txt.twig | 7 + lib/RoadizCoreBundle/templates/emerg.html | 290 + .../templates/fonts/fontfamily.css.twig | 37 + .../templates/nodeSource/default.html.twig | 8 + lib/RoadizCoreBundle/themes/.gitkeep | 0 .../translations/core/messages.ar.xlf | 146 + .../translations/core/messages.de.xlf | 158 + .../translations/core/messages.en.xlf | 158 + .../translations/core/messages.es.xlf | 158 + .../translations/core/messages.fr.xlf | 158 + .../translations/core/messages.id.xlf | 146 + .../translations/core/messages.it.xlf | 146 + .../translations/core/messages.ru.xlf | 158 + .../translations/core/messages.sr.xlf | 158 + .../translations/core/messages.tr.xlf | 150 + .../translations/core/messages.uk.xlf | 158 + .../translations/core/messages.xlf | 171 + .../translations/core/messages.zh.xlf | 158 + .../translations/validators.ar.xlf | 9 + .../translations/validators.de.xlf | 11 + .../translations/validators.en.xlf | 15 + .../translations/validators.es.xlf | 9 + .../translations/validators.fr.xlf | 15 + .../translations/validators.id.xlf | 9 + .../translations/validators.it.xlf | 9 + .../translations/validators.ru.xlf | 9 + .../translations/validators.sr.xlf | 9 + .../translations/validators.tr.xlf | 9 + .../translations/validators.uk.xlf | 11 + .../translations/validators.xlf | 15 + .../translations/validators.zh.xlf | 11 + lib/RoadizFontBundle | 1 - .../.github/workflows/run-test.yml | 41 + lib/RoadizFontBundle/.gitignore | 190 + lib/RoadizFontBundle/.travis.yml | 14 + lib/RoadizFontBundle/LICENSE.md | 9 + lib/RoadizFontBundle/Makefile | 3 + lib/RoadizFontBundle/README.md | 92 + lib/RoadizFontBundle/composer.json | 94 + lib/RoadizFontBundle/config/fixtures.yaml | 6 + .../config/fixtures/roles.json | 6 + .../config/packages/doctrine.yaml | 9 + .../config/packages/flysystem.yaml | 7 + lib/RoadizFontBundle/config/routing.yaml | 41 + lib/RoadizFontBundle/config/services.yaml | 27 + .../migrations/Version20221015083114.php | 36 + lib/RoadizFontBundle/phpcs.xml.dist | 13 + lib/RoadizFontBundle/phpstan.neon | 32 + .../src/Controller/Admin/FontsController.php | 187 + .../src/Controller/FontFaceController.php | 178 + .../DoctrineMigrationCompilerPass.php | 59 + .../RoadizFontExtension.php | 27 + .../FontLifeCycleSubscriber.php | 171 + lib/RoadizFontBundle/src/Entity/Font.php | 597 + .../src/Event/Font/FontEvent.php | 42 + .../src/Event/Font/PreUpdatedFontEvent.php | 9 + .../EventSubscriber/UpdateFontSubscriber.php | 52 + lib/RoadizFontBundle/src/Form/FontType.php | 90 + .../src/Form/FontVariantsType.php | 41 + .../src/Repository/FontRepository.php | 37 + lib/RoadizFontBundle/src/RoadizFontBundle.php | 24 + .../templates/admin/actionsMenu.html.twig | 15 + .../templates/admin/add.html.twig | 37 + .../templates/admin/delete.html.twig | 32 + .../templates/admin/edit.html.twig | 18 + .../templates/admin/list.html.twig | 85 + .../templates/fonts/fontfamily.css.twig | 37 + lib/RoadizRozierBundle | 1 - .../.github/workflows/run-test.yml | 41 + lib/RoadizRozierBundle/.gitignore | 127 + lib/RoadizRozierBundle/.travis.yml | 16 + lib/RoadizRozierBundle/CHANGELOG.md | 92 + lib/RoadizRozierBundle/LICENSE.md | 9 + lib/RoadizRozierBundle/Makefile | 3 + lib/RoadizRozierBundle/README.md | 134 + lib/RoadizRozierBundle/composer.json | 60 + .../config/packages/roadiz_rozier.yaml | 199 + lib/RoadizRozierBundle/config/routing.yaml | 224 + .../config/routing/ajax.yml | 205 + .../config/routing/attributes.yml | 43 + .../config/routing/custom-form-answers.yml | 17 + .../config/routing/custom-forms-fields.yml | 20 + .../config/routing/custom-forms.yml | 29 + .../config/routing/documents.yml | 79 + .../config/routing/folders.yml | 33 + .../config/routing/groups.yml | 47 + .../config/routing/login.yml | 23 + .../config/routing/node-type-fields.yml | 22 + .../config/routing/node-types.yml | 39 + .../config/routing/nodes.yml | 177 + .../config/routing/realms.yml | 14 + .../config/routing/redirections.yml | 14 + .../config/routing/roles.yml | 27 + .../config/routing/setting-groups.yml | 19 + .../config/routing/settings.yml | 27 + .../config/routing/tags.yml | 57 + .../config/routing/translations.yml | 19 + .../config/routing/users.yml | 45 + .../config/routing/webhooks.yml | 22 + lib/RoadizRozierBundle/config/services.yaml | 96 + lib/RoadizRozierBundle/crowdin.yml | 8 + lib/RoadizRozierBundle/deprecated.php | 9 + lib/RoadizRozierBundle/phpcs.xml.dist | 13 + lib/RoadizRozierBundle/phpstan.neon | 35 + lib/RoadizRozierBundle/src/Aliases.php | 21 + .../src/Controller/BackendController.php | 63 + .../CustomForm/CustomFormUsageController.php | 22 + .../Document/DocumentArchiveController.php | 111 + .../Document/DocumentDuplicatesController.php | 68 + .../DocumentLimitationsController.php | 62 + .../Document/DocumentPreviewController.php | 91 + .../DocumentPrivateListController.php | 28 + .../Document/DocumentPublicListController.php | 318 + .../Document/DocumentUnusedController.php | 70 + .../Login/LoginRequestController.php | 78 + .../Controller/Node/NodesTagsController.php | 93 + .../Controller/Node/RealmNodeController.php | 140 + .../src/Controller/Node/SeoController.php | 378 + .../Controller/Node/TranslateController.php | 87 + .../src/Controller/PingController.php | 33 + .../src/Controller/Realm/RealmController.php | 95 + .../src/Controller/SecurityController.php | 75 + .../Compiler/JwtRoleStrategyCompilerPass.php | 34 + .../Compiler/RozierPathsCompilerPass.php | 109 + .../src/DependencyInjection/Configuration.php | 139 + .../RoadizRozierExtension.php | 98 + .../src/Form/CustomFormType.php | 121 + .../DataTransformer/NodesTagsTransformer.php | 64 + .../src/Form/DocumentLimitationsType.php | 59 + .../src/Form/DocumentTranslationType.php | 53 + .../src/Form/NodesTagsType.php | 59 + .../src/Form/TranslateNodeType.php | 91 + .../src/ListManager/SessionListFilters.php | 61 + .../src/RoadizRozierBundle.php | 26 + .../src/Security/RozierAuthenticator.php | 9 + .../templates/custom-forms/navBar.html.twig | 24 + .../templates/custom-forms/usage.html.twig | 54 + .../templates/documents/duplicated.html.twig | 9 + .../templates/documents/limitations.html.twig | 30 + .../templates/documents/list-table.html.twig | 139 + .../templates/documents/list.html.twig | 61 + .../templates/documents/navBar.html.twig | 32 + .../templates/documents/preview.html.twig | 109 + .../templates/documents/unused.html.twig | 17 + .../templates/folders/navBar.html.twig | 16 + .../templates/nodes/actionsMenu.html.twig | 243 + .../templates/nodes/deleteRealm.html.twig | 27 + .../templates/nodes/editAliases.html.twig | 118 + .../templates/nodes/navBar.html.twig | 86 + .../templates/nodes/realms.html.twig | 104 + .../templates/realms/actionsMenu.html.twig | 11 + .../templates/realms/add.html.twig | 39 + .../templates/realms/delete.html.twig | 33 + .../templates/realms/edit.html.twig | 19 + .../templates/realms/list.html.twig | 80 + .../templates/security/login.html.twig | 63 + .../templates/simple.html.twig | 51 + .../templates/users/list.html.twig | 115 + .../translations/attributes/messages.ar.xlf | 80 + .../translations/attributes/messages.de.xlf | 80 + .../translations/attributes/messages.en.xlf | 317 + .../translations/attributes/messages.es.xlf | 11 + .../translations/attributes/messages.fr.xlf | 317 + .../translations/attributes/messages.id.xlf | 317 + .../translations/attributes/messages.it.xlf | 11 + .../translations/attributes/messages.ru.xlf | 80 + .../translations/attributes/messages.sr.xlf | 159 + .../translations/attributes/messages.tr.xlf | 31 + .../translations/attributes/messages.uk.xlf | 11 + .../translations/attributes/messages.xlf | 321 + .../translations/attributes/messages.zh.xlf | 317 + .../translations/custom-forms/messages.ar.xlf | 18 + .../translations/custom-forms/messages.de.xlf | 18 + .../translations/custom-forms/messages.en.xlf | 62 + .../translations/custom-forms/messages.es.xlf | 18 + .../translations/custom-forms/messages.fr.xlf | 62 + .../translations/custom-forms/messages.id.xlf | 18 + .../translations/custom-forms/messages.it.xlf | 18 + .../translations/custom-forms/messages.ru.xlf | 18 + .../translations/custom-forms/messages.sr.xlf | 18 + .../translations/custom-forms/messages.tr.xlf | 18 + .../translations/custom-forms/messages.uk.xlf | 18 + .../translations/custom-forms/messages.xlf | 62 + .../translations/custom-forms/messages.zh.xlf | 18 + .../translations/documents/messages.ar.xlf | 20 + .../translations/documents/messages.de.xlf | 20 + .../translations/documents/messages.en.xlf | 64 + .../translations/documents/messages.es.xlf | 20 + .../translations/documents/messages.fr.xlf | 64 + .../translations/documents/messages.id.xlf | 20 + .../translations/documents/messages.it.xlf | 20 + .../translations/documents/messages.ru.xlf | 20 + .../translations/documents/messages.sr.xlf | 20 + .../translations/documents/messages.tr.xlf | 20 + .../translations/documents/messages.uk.xlf | 20 + .../translations/documents/messages.xlf | 60 + .../translations/documents/messages.zh.xlf | 20 + .../translations/helps/messages.ar.xlf | 24 + .../translations/helps/messages.de.xlf | 24 + .../translations/helps/messages.en.xlf | 61 + .../translations/helps/messages.es.xlf | 24 + .../translations/helps/messages.fr.xlf | 61 + .../translations/helps/messages.id.xlf | 43 + .../translations/helps/messages.it.xlf | 24 + .../translations/helps/messages.ru.xlf | 24 + .../translations/helps/messages.sr.xlf | 24 + .../translations/helps/messages.tr.xlf | 24 + .../translations/helps/messages.uk.xlf | 24 + .../translations/helps/messages.xlf | 61 + .../translations/helps/messages.zh.xlf | 43 + .../translations/realms/messages.ar.xlf | 52 + .../translations/realms/messages.de.xlf | 52 + .../translations/realms/messages.en.xlf | 218 + .../translations/realms/messages.es.xlf | 52 + .../translations/realms/messages.fr.xlf | 218 + .../translations/realms/messages.id.xlf | 52 + .../translations/realms/messages.it.xlf | 52 + .../translations/realms/messages.ru.xlf | 52 + .../translations/realms/messages.sr.xlf | 52 + .../translations/realms/messages.tr.xlf | 52 + .../translations/realms/messages.uk.xlf | 52 + .../translations/realms/messages.xlf | 219 + .../translations/realms/messages.zh.xlf | 52 + .../translations/redirections/messages.ar.xlf | 8 + .../translations/redirections/messages.de.xlf | 8 + .../translations/redirections/messages.en.xlf | 11 + .../translations/redirections/messages.es.xlf | 8 + .../translations/redirections/messages.fr.xlf | 11 + .../translations/redirections/messages.id.xlf | 8 + .../translations/redirections/messages.it.xlf | 8 + .../translations/redirections/messages.ru.xlf | 8 + .../translations/redirections/messages.sr.xlf | 8 + .../translations/redirections/messages.tr.xlf | 8 + .../translations/redirections/messages.uk.xlf | 8 + .../translations/redirections/messages.xlf | 11 + .../translations/redirections/messages.zh.xlf | 8 + .../translations/settings/messages.ar.xlf | 8 + .../translations/settings/messages.de.xlf | 8 + .../translations/settings/messages.en.xlf | 230 + .../translations/settings/messages.es.xlf | 18 + .../translations/settings/messages.fr.xlf | 230 + .../translations/settings/messages.id.xlf | 145 + .../translations/settings/messages.it.xlf | 8 + .../translations/settings/messages.ru.xlf | 8 + .../translations/settings/messages.sr.xlf | 8 + .../translations/settings/messages.tr.xlf | 13 + .../translations/settings/messages.uk.xlf | 8 + .../translations/settings/messages.xlf | 235 + .../translations/settings/messages.zh.xlf | 165 + .../translations/users/messages.ar.xlf | 10 + .../translations/users/messages.de.xlf | 10 + .../translations/users/messages.en.xlf | 19 + .../translations/users/messages.es.xlf | 10 + .../translations/users/messages.fr.xlf | 19 + .../translations/users/messages.id.xlf | 10 + .../translations/users/messages.it.xlf | 10 + .../translations/users/messages.ru.xlf | 10 + .../translations/users/messages.sr.xlf | 10 + .../translations/users/messages.tr.xlf | 10 + .../translations/users/messages.uk.xlf | 10 + .../translations/users/messages.xlf | 19 + .../translations/users/messages.zh.xlf | 10 + .../translations/webhooks/messages.ar.xlf | 33 + .../translations/webhooks/messages.de.xlf | 33 + .../translations/webhooks/messages.en.xlf | 111 + .../translations/webhooks/messages.es.xlf | 47 + .../translations/webhooks/messages.fr.xlf | 111 + .../translations/webhooks/messages.id.xlf | 33 + .../translations/webhooks/messages.it.xlf | 11 + .../translations/webhooks/messages.ru.xlf | 33 + .../translations/webhooks/messages.sr.xlf | 33 + .../translations/webhooks/messages.tr.xlf | 33 + .../translations/webhooks/messages.uk.xlf | 33 + .../translations/webhooks/messages.xlf | 111 + .../translations/webhooks/messages.zh.xlf | 87 + lib/RoadizUserBundle | 1 - .../.github/workflows/run-test.yml | 41 + lib/RoadizUserBundle/.gitignore | 126 + lib/RoadizUserBundle/.travis.yml | 14 + lib/RoadizUserBundle/LICENSE.md | 9 + lib/RoadizUserBundle/Makefile | 3 + lib/RoadizUserBundle/README.md | 110 + lib/RoadizUserBundle/composer.json | 57 + .../config/api_resources/user.yaml | 105 + .../config/packages/roadiz_user.yaml | 20 + lib/RoadizUserBundle/config/routing.yaml | 1 + lib/RoadizUserBundle/config/services.yaml | 31 + lib/RoadizUserBundle/crowdin.yml | 8 + .../migrations/Version20220613144815.php | 32 + .../migrations/Version20220615142220.php | 33 + .../migrations/Version20220718100618.php | 32 + lib/RoadizUserBundle/phpcs.xml.dist | 13 + lib/RoadizUserBundle/phpstan.neon | 34 + .../UserInputDataTransformer.php | 63 + .../UserOutputDataTransformer.php | 70 + ...serPasswordRequestInputDataTransformer.php | 60 + .../UserPasswordTokenInputDataTransformer.php | 101 + .../UserTokenInputDataTransformer.php | 91 + ...rValidationRequestInputDataTransformer.php | 45 + .../VoidOutputDataTransformer.php | 27 + .../src/Api/Dto/UserInput.php | 19 + .../src/Api/Dto/UserOutput.php | 22 + .../src/Api/Dto/UserPasswordRequestInput.php | 10 + .../src/Api/Dto/UserPasswordTokenInput.php | 11 + .../src/Api/Dto/UserTokenInput.php | 10 + .../Api/Dto/UserValidationRequestInput.php | 10 + .../src/Api/Dto/VoidOutput.php | 9 + .../PurgeUserValidationTokenCommand.php | 40 + .../src/Controller/InformationController.php | 33 + .../Controller/PasswordRequestController.php | 156 + .../Controller/PasswordResetController.php | 15 + .../RecaptchaProtectedControllerTrait.php | 31 + .../src/Controller/SignupController.php | 83 + .../src/Controller/ValidateController.php | 28 + .../ValidationRequestController.php | 54 + .../DoctrineMigrationCompilerPass.php | 59 + .../src/DependencyInjection/Configuration.php | 60 + .../RoadizUserExtension.php | 32 + .../src/Entity/UserMetadata.php | 79 + .../src/Entity/UserValidationToken.php | 106 + .../src/Event/UserEmailValidated.php | 11 + .../src/Event/UserSignedUp.php | 11 + .../UserSignedUpSubscriber.php | 36 + .../src/Manager/UserMetadataManager.php | 35 + .../Manager/UserMetadataManagerInterface.php | 14 + .../Manager/UserValidationTokenManager.php | 134 + .../UserValidationTokenManagerInterface.php | 15 + .../UserValidationTokenRepository.php | 32 + lib/RoadizUserBundle/src/RoadizUserBundle.php | 24 + .../users/reset_password_email.html.twig | 63 + .../email/users/reset_password_email.txt.twig | 26 + .../email/users/validate_email.html.twig | 59 + .../email/users/validate_email.txt.twig | 24 + .../translations/email/messages.ar.xlf | 12 + .../translations/email/messages.de.xlf | 27 + .../translations/email/messages.en.xlf | 27 + .../translations/email/messages.es.xlf | 12 + .../translations/email/messages.fr.xlf | 27 + .../translations/email/messages.id.xlf | 12 + .../translations/email/messages.it.xlf | 12 + .../translations/email/messages.ru.xlf | 12 + .../translations/email/messages.sr.xlf | 12 + .../translations/email/messages.tr.xlf | 12 + .../translations/email/messages.uk.xlf | 12 + .../translations/email/messages.xlf | 27 + .../translations/email/messages.zh.xlf | 19 + lib/Rozier | 1 - lib/Rozier/.editorconfig | 17 + lib/Rozier/.github/workflows/run-test.yml | 41 + lib/Rozier/.gitignore | 121 + lib/Rozier/.travis.yml | 75 + lib/Rozier/.travis/backoffice_assets.sh | 5 + lib/Rozier/.travis/composer_install.sh | 4 + lib/Rozier/.travis/php_lint.sh | 5 + lib/Rozier/CHANGELOG.md | 195 + lib/Rozier/LICENSE.md | 9 + lib/Rozier/Makefile | 5 + lib/Rozier/README.md | 17 + lib/Rozier/composer.json | 97 + lib/Rozier/crowdin.yml | 8 + lib/Rozier/phpcs.xml.dist | 26 + lib/Rozier/phpstan.neon | 36 + lib/Rozier/src/.babelrc | 4 + lib/Rozier/src/.editorconfig | 21 + lib/Rozier/src/.eslintignore | 9 + lib/Rozier/src/.eslintrc.js | 25 + lib/Rozier/src/.gitignore | 14 + lib/Rozier/src/.postcssrc.js | 9 + lib/Rozier/src/.prettierrc.js | 6 + .../AbstractAjaxController.php | 67 + .../AjaxAbstractFieldsController.php | 108 + .../AjaxAttributeValuesController.php | 103 + .../AjaxCustomFormFieldsController.php | 46 + .../AjaxCustomFormsExplorerController.php | 126 + .../AjaxDocumentsExplorerController.php | 188 + .../AjaxEntitiesExplorerController.php | 211 + .../AjaxExplorerProviderController.php | 153 + .../AjaxFolderTreeController.php | 82 + .../AjaxControllers/AjaxFoldersController.php | 174 + .../AjaxFoldersExplorerController.php | 66 + .../AjaxNodeTreeController.php | 146 + .../AjaxNodeTypeFieldsController.php | 46 + .../AjaxNodeTypesController.php | 116 + .../AjaxControllers/AjaxNodesController.php | 418 + .../AjaxNodesExplorerController.php | 293 + .../AjaxSearchNodesSourcesController.php | 124 + .../AjaxControllers/AjaxSessionMessages.php | 38 + .../AjaxControllers/AjaxTagTreeController.php | 82 + .../AjaxControllers/AjaxTagsController.php | 389 + .../Controllers/AbstractAdminController.php | 540 + .../Attributes/AttributeController.php | 165 + .../Attributes/AttributeGroupController.php | 108 + .../src/Controllers/CacheController.php | 126 + .../CustomFormAnswersController.php | 122 + .../CustomFormFieldAttributesController.php | 67 + .../CustomFormFieldsController.php | 238 + .../CustomForms/CustomFormsController.php | 108 + .../CustomFormsUtilsController.php | 144 + .../src/Controllers/DashboardController.php | 37 + .../DocumentTranslationsController.php | 272 + .../Documents/DocumentsController.php | 1119 ++ .../src/Controllers/FoldersController.php | 347 + .../src/Controllers/GroupsController.php | 372 + .../src/Controllers/GroupsUtilsController.php | 159 + .../src/Controllers/HistoryController.php | 112 + .../src/Controllers/LoginController.php | 67 + .../src/Controllers/LoginResetController.php | 57 + .../Controllers/NodeTypeFieldsController.php | 215 + .../NodeTypes/NodeTypesController.php | 186 + .../NodeTypes/NodeTypesUtilsController.php | 250 + .../Controllers/Nodes/ExportController.php | 96 + .../Controllers/Nodes/HistoryController.php | 61 + .../Nodes/NodesAttributesController.php | 329 + .../src/Controllers/Nodes/NodesController.php | 764 ++ .../Nodes/NodesSourcesController.php | 309 + .../Controllers/Nodes/NodesTagsController.php | 104 + .../Nodes/NodesTreesController.php | 564 + .../Nodes/NodesUtilsController.php | 84 + .../Controllers/Nodes/TranstypeController.php | 112 + .../Nodes/UrlAliasesController.php | 380 + lib/Rozier/src/Controllers/PingController.php | 26 + .../Controllers/RedirectionsController.php | 104 + .../src/Controllers/RolesController.php | 151 + .../src/Controllers/RolesUtilsController.php | 136 + .../src/Controllers/SearchController.php | 690 + .../Controllers/SettingGroupsController.php | 104 + .../src/Controllers/SettingsController.php | 337 + .../Controllers/SettingsUtilsController.php | 141 + .../Tags/TagMultiCreationController.php | 99 + .../src/Controllers/Tags/TagsController.php | 733 ++ .../Controllers/Tags/TagsUtilsController.php | 93 + .../Controllers/TranslationsController.php | 202 + .../src/Controllers/Users/UsersController.php | 242 + .../Users/UsersGroupsController.php | 161 + .../Users/UsersRolesController.php | 153 + .../Users/UsersSecurityController.php | 63 + .../src/Controllers/WebhookController.php | 133 + .../src/Explorer/ConfigurableExplorerItem.php | 119 + .../src/Explorer/FolderExplorerItem.php | 65 + lib/Rozier/src/Explorer/FoldersProvider.php | 50 + .../src/Explorer/SettingExplorerItem.php | 63 + lib/Rozier/src/Explorer/SettingsProvider.php | 50 + lib/Rozier/src/Explorer/UserExplorerItem.php | 68 + lib/Rozier/src/Explorer/UsersProvider.php | 50 + lib/Rozier/src/Forms/AddUserType.php | 32 + lib/Rozier/src/Forms/CustomFormFieldType.php | 86 + .../Forms/DataTransformer/TagTransformer.php | 79 + lib/Rozier/src/Forms/DocumentEditType.php | 184 + lib/Rozier/src/Forms/DocumentEmbedType.php | 62 + .../src/Forms/DocumentTranslationType.php | 49 + lib/Rozier/src/Forms/DynamicType.php | 49 + lib/Rozier/src/Forms/FolderCollectionType.php | 84 + .../src/Forms/FolderTranslationType.php | 40 + lib/Rozier/src/Forms/FolderType.php | 57 + lib/Rozier/src/Forms/GeoJsonType.php | 35 + lib/Rozier/src/Forms/GroupType.php | 38 + lib/Rozier/src/Forms/LoginType.php | 95 + lib/Rozier/src/Forms/MultiTagType.php | 36 + lib/Rozier/src/Forms/Node/AddNodeType.php | 146 + ...bstractConfigurableNodeSourceFieldType.php | 23 + .../AbstractNodeSourceFieldType.php | 67 + .../Forms/NodeSource/NodeSourceBaseType.php | 82 + .../NodeSource/NodeSourceCollectionType.php | 32 + .../NodeSource/NodeSourceCustomFormType.php | 121 + .../NodeSource/NodeSourceDocumentType.php | 130 + .../Forms/NodeSource/NodeSourceJoinType.php | 100 + .../Forms/NodeSource/NodeSourceNodeType.php | 127 + .../NodeSource/NodeSourceProviderType.php | 146 + .../Forms/NodeSource/NodeSourceSeoType.php | 63 + .../src/Forms/NodeSource/NodeSourceType.php | 484 + lib/Rozier/src/Forms/NodeTagsType.php | 63 + lib/Rozier/src/Forms/NodeTreeType.php | 141 + lib/Rozier/src/Forms/NodeType.php | 86 + .../Forms/NodeTypeFieldSerializationType.php | 88 + lib/Rozier/src/Forms/NodeTypeFieldType.php | 122 + lib/Rozier/src/Forms/NodeTypeType.php | 92 + lib/Rozier/src/Forms/RedirectionType.php | 61 + lib/Rozier/src/Forms/RoleType.php | 41 + lib/Rozier/src/Forms/SettingGroupType.php | 46 + lib/Rozier/src/Forms/TagTranslationType.php | 63 + lib/Rozier/src/Forms/TagType.php | 75 + lib/Rozier/src/Forms/TranslationType.php | 55 + lib/Rozier/src/Forms/TranstypeType.php | 106 + lib/Rozier/src/Forms/UserDetailsType.php | 94 + lib/Rozier/src/Forms/UserSecurityType.php | 91 + lib/Rozier/src/Forms/UserType.php | 54 + lib/Rozier/src/Models/CustomFormModel.php | 51 + lib/Rozier/src/Models/DocumentModel.php | 149 + lib/Rozier/src/Models/ModelInterface.php | 18 + lib/Rozier/src/Models/NodeModel.php | 89 + lib/Rozier/src/Models/NodeSourceModel.php | 70 + lib/Rozier/src/Models/NodeTypeModel.php | 33 + lib/Rozier/src/Models/TagModel.php | 75 + lib/Rozier/src/Resources/app/App.js | 130 + lib/Rozier/src/Resources/app/Lazyload.js | 530 + lib/Rozier/src/Resources/app/Rozier.js | 879 ++ lib/Rozier/src/Resources/app/RozierMobile.js | 347 + .../src/Resources/app/api/CustomFormApi.js | 77 + .../src/Resources/app/api/DocumentApi.js | 90 + lib/Rozier/src/Resources/app/api/DrawerApi.js | 45 + .../src/Resources/app/api/ExplorerApi.js | 55 + .../Resources/app/api/ExplorerProviderApi.js | 81 + .../Resources/app/api/FilterExplorerApi.js | 21 + .../Resources/app/api/FolderExplorerApi.js | 33 + lib/Rozier/src/Resources/app/api/JoinApi.js | 77 + lib/Rozier/src/Resources/app/api/NodeApi.js | 85 + .../src/Resources/app/api/NodeTypeApi.js | 80 + .../Resources/app/api/NodesSourceSearchApi.js | 31 + .../src/Resources/app/api/SplashScreenApi.js | 23 + lib/Rozier/src/Resources/app/api/TagApi.js | 116 + .../src/Resources/app/api/TagExplorerApi.js | 62 + lib/Rozier/src/Resources/app/api/index.js | 19 + .../assets/img/apple-touch-icon-114x114.png | Bin 0 -> 2125 bytes .../assets/img/apple-touch-icon-120x120.png | Bin 0 -> 2189 bytes .../assets/img/apple-touch-icon-144x144.png | Bin 0 -> 2607 bytes .../assets/img/apple-touch-icon-152x152.png | Bin 0 -> 2736 bytes .../assets/img/apple-touch-icon-180x180.png | Bin 0 -> 3208 bytes .../app/assets/img/apple-touch-icon-57x57.png | Bin 0 -> 1220 bytes .../app/assets/img/apple-touch-icon-60x60.png | Bin 0 -> 1264 bytes .../app/assets/img/apple-touch-icon-72x72.png | Bin 0 -> 1490 bytes .../app/assets/img/apple-touch-icon-76x76.png | Bin 0 -> 1543 bytes .../img/apple-touch-icon-precomposed.png | Bin 0 -> 3153 bytes .../app/assets/img/apple-touch-icon.png | Bin 0 -> 3208 bytes .../app/assets/img/browserconfig.xml | 12 + .../app/assets/img/default_login.jpg | Bin 0 -> 343413 bytes .../app/assets/img/favicon-160x160.png | Bin 0 -> 2847 bytes .../app/assets/img/favicon-16x16.png | Bin 0 -> 536 bytes .../app/assets/img/favicon-192x192.png | Bin 0 -> 3415 bytes .../app/assets/img/favicon-32x32.png | Bin 0 -> 797 bytes .../app/assets/img/favicon-96x96.png | Bin 0 -> 1812 bytes .../src/Resources/app/assets/img/favicon.ico | Bin 0 -> 15086 bytes .../app/assets/img/jquery.minicolors.png | Bin 0 -> 76147 bytes .../ui-bg_diagonals-thick_18_b81900_40x40.png | Bin 0 -> 223 bytes .../ui-bg_diagonals-thick_20_666666_40x40.png | Bin 0 -> 171 bytes .../jqueryui/ui-bg_flat_10_000000_40x100.png | Bin 0 -> 71 bytes .../jqueryui/ui-bg_glass_100_f6f6f6_1x400.png | Bin 0 -> 123 bytes .../jqueryui/ui-bg_glass_100_fdf5ce_1x400.png | Bin 0 -> 193 bytes .../jqueryui/ui-bg_glass_65_ffffff_1x400.png | Bin 0 -> 74 bytes .../ui-bg_gloss-wave_35_f6a828_500x100.png | Bin 0 -> 5115 bytes .../ui-bg_highlight-soft_100_eeeeee_1x100.png | Bin 0 -> 125 bytes .../ui-bg_highlight-soft_75_ffe45c_1x100.png | Bin 0 -> 167 bytes .../img/jqueryui/ui-icons_222222_256x240.png | Bin 0 -> 4262 bytes .../img/jqueryui/ui-icons_228ef1_256x240.png | Bin 0 -> 4261 bytes .../img/jqueryui/ui-icons_ef8c08_256x240.png | Bin 0 -> 4261 bytes .../img/jqueryui/ui-icons_ffd27a_256x240.png | Bin 0 -> 4261 bytes .../img/jqueryui/ui-icons_ffffff_256x240.png | Bin 0 -> 4262 bytes .../Resources/app/assets/img/map_marker.png | Bin 0 -> 1408 bytes .../src/Resources/app/assets/img/marker.png | Bin 0 -> 1100 bytes .../Resources/app/assets/img/marker@2x.png | Bin 0 -> 2445 bytes .../app/assets/img/marker_shadow.png | Bin 0 -> 434 bytes .../app/assets/img/marker_shadow@2x.png | Bin 0 -> 776 bytes .../Resources/app/assets/img/matrix@2x.png | Bin 0 -> 1341 bytes .../app/assets/img/mstile-144x144.png | Bin 0 -> 2499 bytes .../app/assets/img/mstile-150x150.png | Bin 0 -> 2551 bytes .../app/assets/img/mstile-310x150.png | Bin 0 -> 2769 bytes .../app/assets/img/mstile-310x310.png | Bin 0 -> 5365 bytes .../Resources/app/assets/img/mstile-70x70.png | Bin 0 -> 1777 bytes .../Resources/app/assets/img/spritemap.png | Bin 0 -> 10208 bytes .../Resources/app/assets/img/spritemap@2x.png | Bin 0 -> 35675 bytes .../src/Resources/app/components/AjaxLink.vue | 21 + .../app/components/BlanchetteToolbar.vue | 274 + .../Resources/app/components/CodeMirror.vue | 85 + .../app/components/CustomFormPreviewItem.vue | 31 + .../components/DocumentPreviewListItem.vue | 144 + .../Resources/app/components/DrawerItem.vue | 132 + .../src/Resources/app/components/Dropzone.vue | 221 + .../app/components/ExplorerItemsInfos.vue | 22 + .../app/components/FilterExplorerButton.vue | 33 + .../app/components/FilterExplorerItem.vue | 59 + .../app/components/JoinPreviewItem.vue | 30 + .../app/components/LoadMoreButton.vue | 36 + .../app/components/NodePreviewItem.vue | 32 + .../app/components/NodeTypePreviewItem.vue | 31 + .../src/Resources/app/components/Overlay.vue | 16 + .../src/Resources/app/components/RzButton.vue | 14 + .../Resources/app/components/RzTextarea.vue | 26 + .../app/components/TagCreatorItem.vue | 120 + .../app/components/TagPreviewItem.vue | 30 + .../Resources/app/components/WarningModal.vue | 219 + .../AttributeValuePosition.js | 86 + .../components/bulk-edits/DocumentsBulk.js | 160 + .../app/components/bulk-edits/NodesBulk.js | 167 + .../app/components/bulk-edits/TagsBulk.js | 172 + .../custom-form-fields/CustomFormFieldEdit.js | 159 + .../CustomFormFieldsPosition.js | 70 + .../components/documents/DocumentUploader.js | 144 + .../app/components/documents/DocumentsList.js | 24 + .../Resources/app/components/import/Import.js | 119 + .../Resources/app/components/login/login.js | 37 + .../node-type-fields/NodeTypeFieldEdit.js | 167 + .../NodeTypeFieldsPosition.js | 118 + .../app/components/node/NodeEditSource.js | 320 + .../app/components/panels/EntriesPanel.js | 28 + .../app/components/tabs/MainTreeTabs.js | 32 + .../Resources/app/components/tag/TagEdit.js | 131 + .../trees/NodeTreeContextActions.js | 196 + .../containers/BlanchetteEditorContainer.vue | 269 + .../containers/DocumentPreviewContainer.vue | 208 + .../app/containers/DrawerContainer.vue | 158 + .../app/containers/ExplorerContainer.vue | 147 + .../containers/FilterExplorerContainer.vue | 77 + .../app/containers/ModalContainer.vue | 47 + .../containers/NodeTypeFieldFormContainer.vue | 179 + .../containers/NodeTypesDrawerContainer.vue | 125 + .../app/containers/NodesSearchContainer.vue | 50 + .../app/containers/TagCreatorContainer.vue | 68 + .../app/containers/TagsEditorContainer.vue | 70 + .../Resources/app/directives/DynamicImg.js | 20 + .../app/factories/EntityAwareFactory.js | 95 + .../Resources/app/filters/centralTruncate.js | 10 + lib/Rozier/src/Resources/app/filters/index.js | 7 + .../src/Resources/app/filters/truncate.js | 9 + .../Resources/app/fonts/fa-brands-400.woff | Bin 0 -> 87688 bytes .../Resources/app/fonts/fa-brands-400.woff2 | Bin 0 -> 74768 bytes .../app/fonts/fontawesome-webfont.woff | Bin 0 -> 83760 bytes .../app/fonts/fontawesome-webfont.woff2 | Bin 0 -> 56084 bytes .../fonts/roadiz-sans/RoadizSans-Light.eot | Bin 0 -> 23702 bytes .../fonts/roadiz-sans/RoadizSans-Light.woff | Bin 0 -> 26568 bytes .../fonts/roadiz-sans/RoadizSans-Light.woff2 | Bin 0 -> 21212 bytes .../fonts/roadiz-sans/RoadizSans-Medium.eot | Bin 0 -> 23720 bytes .../fonts/roadiz-sans/RoadizSans-Medium.woff | Bin 0 -> 26472 bytes .../fonts/roadiz-sans/RoadizSans-Medium.woff2 | Bin 0 -> 21164 bytes .../app/fonts/roadiz-sans/RoadizSans-Mono.eot | Bin 0 -> 22520 bytes .../fonts/roadiz-sans/RoadizSans-Mono.woff | Bin 0 -> 25156 bytes .../fonts/roadiz-sans/RoadizSans-Mono.woff2 | Bin 0 -> 20188 bytes .../fonts/roadiz-sans/RoadizSans-Regular.eot | Bin 0 -> 24157 bytes .../fonts/roadiz-sans/RoadizSans-Regular.woff | Bin 0 -> 26908 bytes .../roadiz-sans/RoadizSans-Regular.woff2 | Bin 0 -> 21544 bytes .../fonts/roadiz-sans/roadiz-sans-LICENCE.md | 27 + .../Resources/app/fonts/rz-icons-LICENSE.md | 25 + .../src/Resources/app/fonts/rz-icons.sfd | 7736 +++++++++++ .../src/Resources/app/fonts/rz-icons.woff | Bin 0 -> 15724 bytes .../src/Resources/app/fonts/rz-icons.woff2 | Bin 0 -> 12264 bytes .../app/less/actions_menu/actions_menu.less | 417 + .../src/Resources/app/less/alerts/alerts.less | 141 + .../app/less/animations/fade-in.less | 7 + .../Resources/app/less/animations/fade.less | 7 + .../app/less/animations/slide-left.less | 20 + .../app/less/attributes/attributes.less | 4 + .../app/less/autocomplete/autocomplete.less | 41 + .../less/autocomplete/tag-autocomplete.less | 100 + .../src/Resources/app/less/badges/badges.less | 52 + .../Resources/app/less/base/boilerplate.less | 285 + .../src/Resources/app/less/base/easing.less | 46 + .../src/Resources/app/less/base/elements.less | 182 + .../Resources/app/less/base/normalize.less | 528 + .../app/less/breadcrumb/breadcrumb.less | 65 + .../src/Resources/app/less/bulk/bulk.less | 160 + .../Resources/app/less/buttons/buttons.less | 450 + lib/Rozier/src/Resources/app/less/common.less | 133 + .../custom-forms-fields.less | 100 + .../app/less/custom-forms-front.less | 114 + .../app/less/custom-forms/custom-forms.less | 34 + .../app/less/dashboard/latestSources.less | 77 + .../app/less/documents/documents.less | 315 + .../Resources/app/less/dropdown/dropdown.less | 192 + .../Resources/app/less/dropzone/dropzone.less | 206 + .../src/Resources/app/less/fonts/global.less | 13 + .../app/less/forms/collection_form.less | 177 + .../app/less/forms/custom_switch.less | 67 + .../src/Resources/app/less/forms/forms.less | 531 + .../Resources/app/less/forms/inline_form.less | 16 + .../app/less/forms/markdown_editor.less | 337 + .../Resources/app/less/grid/grid_layout.less | 110 + .../Resources/app/less/history/history.less | 156 + .../src/Resources/app/less/icons/icons.less | 526 + .../src/Resources/app/less/login/login.less | 313 + .../src/Resources/app/less/login/purge.less | 25 + .../Resources/app/less/navbars/common.less | 50 + .../Resources/app/less/navbars/filterbar.less | 75 + .../app/less/navbars/navigation.less | 92 + .../app/less/navbars/translationbar.less | 125 + .../app/less/node-type-fields/fields.less | 103 + .../app/less/node-types/node-types.less | 45 + .../src/Resources/app/less/nodes/edit.less | 122 + .../src/Resources/app/less/nodes/global.less | 222 + .../panels/entries_panel/admin_entries.less | 331 + .../main_content_panel/main_content.less | 203 + .../less/panels/trees_panel/trees_panel.less | 469 + .../less/panels/user_panel/user_panel.less | 304 + .../app/less/responsive/less-1280.less | 24 + .../app/less/responsive/less-768.less | 839 ++ .../src/Resources/app/less/search/search.less | 113 + .../Resources/app/less/settings/global.less | 38 + lib/Rozier/src/Resources/app/less/style.less | 218 + .../src/Resources/app/less/tables/tables.less | 183 + .../src/Resources/app/less/tags/global.less | 65 + .../src/Resources/app/less/themes/import.less | 31 + .../src/Resources/app/less/themes/themes.less | 64 + .../app/less/translations/global.less | 3 + .../src/Resources/app/less/users/users.less | 45 + lib/Rozier/src/Resources/app/less/vars.less | 146 + lib/Rozier/src/Resources/app/less/vendor.less | 23 + .../vendor/bootstrap3/bootstrap-switch.css | 206 + .../bootstrap3/bootstrap-switch.min.css | 22 + .../Resources/app/less/vendor/codemirror.css | 301 + .../Resources/app/less/vendor/dropzone.less | 392 + .../Resources/app/less/vendor/jquery-ui.less | 548 + .../app/less/vendor/jquery.minicolors.css | 268 + .../less/vendor/uikit/addons/uikit.addons.css | 1124 ++ .../vendor/uikit/addons/uikit.addons.min.css | 3 + .../uikit/addons/uikit.almost-flat.addons.css | 1205 ++ .../addons/uikit.almost-flat.addons.min.css | 3 + .../uikit/addons/uikit.gradient.addons.css | 1226 ++ .../addons/uikit.gradient.addons.min.css | 3 + .../less/vendor/uikit/uikit.almost-flat.css | 7333 +++++++++++ .../vendor/uikit/uikit.almost-flat.min.css | 2 + .../Resources/app/less/vendor/uikit/uikit.css | 7045 ++++++++++ .../app/less/vendor/uikit/uikit.gradient.css | 7401 +++++++++++ .../less/vendor/uikit/uikit.gradient.min.css | 2 + .../app/less/vendor/uikit/uikit.min.css | 2 + .../less/widgets/children_nodes_widget.less | 558 + .../app/less/widgets/customform_widget.less | 163 + .../app/less/widgets/debugpanel.less | 93 + .../app/less/widgets/documents_widget.less | 247 + .../less/widgets/drawer_document_item.less | 160 + .../app/less/widgets/drawer_item.less | 243 + .../app/less/widgets/drawer_widget.less | 177 + .../Resources/app/less/widgets/explorer.less | 377 + .../app/less/widgets/filter_explorer.less | 100 + .../app/less/widgets/folder_tree.less | 125 + .../app/less/widgets/geotag_widget.less | 124 + .../Resources/app/less/widgets/nestable.less | 539 + .../Resources/app/less/widgets/node_tree.less | 93 + .../app/less/widgets/nodes_widget.less | 12 + .../Resources/app/less/widgets/sortable.less | 10 + .../Resources/app/less/widgets/spinner.less | 37 + .../app/less/widgets/stack_tree.less | 67 + .../Resources/app/less/widgets/tag_tree.less | 124 + lib/Rozier/src/Resources/app/main.js | 57 + lib/Rozier/src/Resources/app/scss/styles.scss | 10 + .../app/services/GeoCodingService.js | 26 + .../app/services/KeyboardEventService.js | 42 + .../app/services/LoginCheckService.js | 56 + lib/Rozier/src/Resources/app/simple.js | 11 + lib/Rozier/src/Resources/app/store/index.js | 81 + .../modules/BlanchetteEditorStoreModule.js | 94 + .../modules/DocumentPreviewStoreModule.js | 79 + .../app/store/modules/DrawersStoreModule.js | 264 + .../app/store/modules/ExplorerStoreModule.js | 239 + .../modules/FilterExplorerStoreModule.js | 133 + .../modules/NodesSourceSearchStoreModule.js | 117 + .../app/store/modules/TagsStoreModule.js | 121 + .../src/Resources/app/types/entityTypes.js | 7 + .../src/Resources/app/types/mutationTypes.js | 98 + lib/Rozier/src/Resources/app/utils/index.js | 13 + lib/Rozier/src/Resources/app/utils/plugins.js | 198 + .../Resources/app/vendor/jquery.collection.js | 995 ++ lib/Rozier/src/Resources/app/vendor/jquery.js | 2 + .../Resources/app/vendor/jquery.tag-editor.js | 370 + .../app/vendor/modernizr.custom.50380.js | 4 + lib/Rozier/src/Resources/app/vendor/vue.js | 10826 ++++++++++++++++ lib/Rozier/src/Resources/app/vendor/vuex.js | 938 ++ .../app/widgets/ChildrenNodesField.js | 168 + .../src/Resources/app/widgets/CssEditor.js | 92 + .../app/widgets/FolderAutocomplete.js | 60 + .../app/widgets/InputLengthWatcher.js | 63 + .../src/Resources/app/widgets/JsonEditor.js | 90 + .../app/widgets/LeafletGeotagField.js | 338 + .../Resources/app/widgets/MarkdownEditor.js | 526 + .../app/widgets/MultiLeafletGeotagField.js | 341 + .../src/Resources/app/widgets/NodeStatuses.js | 123 + .../src/Resources/app/widgets/NodeTree.js | 40 + .../src/Resources/app/widgets/SaveButtons.js | 61 + .../app/widgets/SettingsSaveButtons.js | 96 + .../Resources/app/widgets/StackNodeTree.js | 236 + .../Resources/app/widgets/TagAutocomplete.js | 65 + .../src/Resources/app/widgets/YamlEditor.js | 107 + lib/Rozier/src/Resources/routes.yml | 245 + .../src/Resources/translations/helps.ar.xlf | 23 + .../src/Resources/translations/helps.de.xlf | 23 + .../src/Resources/translations/helps.en.xlf | 69 + .../src/Resources/translations/helps.es.xlf | 23 + .../src/Resources/translations/helps.fr.xlf | 69 + .../src/Resources/translations/helps.id.xlf | 54 + .../src/Resources/translations/helps.it.xlf | 23 + .../src/Resources/translations/helps.ru.xlf | 23 + .../src/Resources/translations/helps.sr.xlf | 23 + .../src/Resources/translations/helps.tr.xlf | 23 + .../src/Resources/translations/helps.uk.xlf | 23 + .../src/Resources/translations/helps.xlf | 69 + .../src/Resources/translations/helps.zh.xlf | 54 + .../Resources/translations/messages.ar.xlf | 650 + .../Resources/translations/messages.de.xlf | 806 ++ .../Resources/translations/messages.en.xlf | 4689 +++++++ .../Resources/translations/messages.es.xlf | 4303 ++++++ .../Resources/translations/messages.fr.xlf | 4689 +++++++ .../Resources/translations/messages.id.xlf | 4271 ++++++ .../Resources/translations/messages.it.xlf | 4350 +++++++ .../Resources/translations/messages.ru.xlf | 2087 +++ .../Resources/translations/messages.sr.xlf | 4448 +++++++ .../Resources/translations/messages.tr.xlf | 4124 ++++++ .../Resources/translations/messages.uk.xlf | 3730 ++++++ .../src/Resources/translations/messages.xlf | 1322 ++ .../Resources/translations/messages.zh.xlf | 4619 +++++++ .../Resources/translations/settings.ar.xlf | 8 + .../Resources/translations/settings.de.xlf | 8 + .../Resources/translations/settings.en.xlf | 230 + .../Resources/translations/settings.es.xlf | 18 + .../Resources/translations/settings.fr.xlf | 230 + .../Resources/translations/settings.id.xlf | 145 + .../Resources/translations/settings.it.xlf | 8 + .../Resources/translations/settings.ru.xlf | 8 + .../Resources/translations/settings.sr.xlf | 8 + .../Resources/translations/settings.tr.xlf | 13 + .../Resources/translations/settings.uk.xlf | 8 + .../src/Resources/translations/settings.xlf | 235 + .../Resources/translations/settings.zh.xlf | 165 + .../src/Resources/views/admin/base.html.twig | 21 + .../views/admin/blocks/adminImage.html.twig | 7 + .../views/admin/blocks/loginImage.html.twig | 7 + .../Resources/views/admin/meta-icon.html.twig | 26 + .../admin/webhooks/actionsMenu.html.twig | 17 + .../views/admin/webhooks/add.html.twig | 30 + .../views/admin/webhooks/delete.html.twig | 29 + .../views/admin/webhooks/edit.html.twig | 31 + .../views/admin/webhooks/list.html.twig | 83 + .../views/admin/webhooks/trigger.html.twig | 29 + .../src/Resources/views/ajaxBase.html.twig | 6 + .../views/attributes/actionsMenu.html.twig | 12 + .../Resources/views/attributes/add.html.twig | 27 + .../views/attributes/delete.html.twig | 26 + .../Resources/views/attributes/edit.html.twig | 28 + .../attributes/groups/actionsMenu.html.twig | 12 + .../views/attributes/groups/add.html.twig | 27 + .../views/attributes/groups/delete.html.twig | 26 + .../views/attributes/groups/edit.html.twig | 28 + .../views/attributes/groups/list.html.twig | 64 + .../views/attributes/import.html.twig | 22 + .../Resources/views/attributes/list.html.twig | 71 + lib/Rozier/src/Resources/views/base.html.twig | 238 + .../views/cache/deleteAssets.html.twig | 24 + .../views/cache/deleteDoctrine.html.twig | 62 + .../Resources/views/css/mainColor.css.twig | 66 + .../custom-form-answers/delete.html.twig | 25 + .../views/custom-form-answers/list.html.twig | 82 + .../list.html.twig | 66 + .../custom-form-fields/actionsMenu.html.twig | 13 + .../views/custom-form-fields/add.html.twig | 30 + .../custom-form-fields/ajaxEditBase.html.twig | 1 + .../views/custom-form-fields/delete.html.twig | 29 + .../views/custom-form-fields/edit.html.twig | 18 + .../custom-form-fields/editBase.html.twig | 14 + .../views/custom-form-fields/list.html.twig | 59 + .../views/custom-forms/actionsMenu.html.twig | 16 + .../views/custom-forms/add.html.twig | 39 + .../views/custom-forms/delete.html.twig | 36 + .../views/custom-forms/edit.html.twig | 32 + .../views/custom-forms/list.html.twig | 107 + .../views/custom-forms/navBar.html.twig | 21 + .../Resources/views/dashboard/index.html.twig | 75 + .../views/dashboard/navBar.html.twig | 14 + .../document-translations/delete.html.twig | 30 + .../document-translations/edit.html.twig | 61 + .../translationBar.html.twig | 18 + .../views/documents/actionsMenu.html.twig | 43 + .../views/documents/adjust.html.twig | 23 + .../views/documents/backLink.html.twig | 7 + .../views/documents/bulkDelete.html.twig | 42 + .../views/documents/bulkDownload.html.twig | 37 + .../views/documents/delete.html.twig | 27 + .../Resources/views/documents/edit.html.twig | 76 + .../Resources/views/documents/embed.html.twig | 31 + .../views/documents/filtersBar.html.twig | 87 + .../views/documents/itemPerPage.html.twig | 5 + .../views/documents/navBar.html.twig | 27 + .../singleDocumentThumbnail.html.twig | 40 + .../views/documents/upload.html.twig | 34 + .../Resources/views/documents/usage.html.twig | 111 + .../views/folders/actionsMenu.html.twig | 17 + .../src/Resources/views/folders/add.html.twig | 29 + .../Resources/views/folders/delete.html.twig | 25 + .../Resources/views/folders/edit.html.twig | 58 + .../Resources/views/folders/list.html.twig | 62 + .../Resources/views/folders/navBar.html.twig | 13 + .../views/folders/translationBar.html.twig | 18 + .../src/Resources/views/forms.html.twig | 546 + .../views/groups/actionsMenu.html.twig | 13 + .../src/Resources/views/groups/add.html.twig | 29 + .../Resources/views/groups/delete.html.twig | 26 + .../src/Resources/views/groups/edit.html.twig | 34 + .../Resources/views/groups/import.html.twig | 24 + .../src/Resources/views/groups/list.html.twig | 85 + .../Resources/views/groups/navBar.html.twig | 13 + .../views/groups/removeRole.html.twig | 24 + .../views/groups/removeUser.html.twig | 25 + .../Resources/views/groups/roles.html.twig | 64 + .../Resources/views/groups/users.html.twig | 62 + .../Resources/views/history/list.html.twig | 46 + .../Resources/views/horizontalForms.html.twig | 42 + .../views/includes/column_ordering.html.twig | 38 + .../views/includes/messages.html.twig | 11 + .../src/Resources/views/layout.html.twig | 6 + .../src/Resources/views/login/base.html.twig | 44 + .../src/Resources/views/login/check.html.twig | 5 + .../Resources/views/login/request.html.twig | 19 + .../views/login/requestConfirm.html.twig | 10 + .../src/Resources/views/login/reset.html.twig | 16 + .../views/login/resetConfirm.html.twig | 10 + .../views/modules/history-item.html.twig | 96 + .../node-type-fields/actionsMenu.html.twig | 13 + .../views/node-type-fields/add.html.twig | 32 + .../node-type-fields/ajaxEditBase.html.twig | 1 + .../views/node-type-fields/delete.html.twig | 26 + .../views/node-type-fields/edit.html.twig | 20 + .../views/node-type-fields/editBase.html.twig | 16 + .../views/node-type-fields/list.html.twig | 67 + .../views/node-types/actionsMenu.html.twig | 17 + .../Resources/views/node-types/add.html.twig | 31 + .../views/node-types/delete.html.twig | 26 + .../Resources/views/node-types/edit.html.twig | 51 + .../views/node-types/import.html.twig | 22 + .../Resources/views/node-types/list.html.twig | 109 + .../views/node-types/navBar.html.twig | 12 + .../views/nodes/actionsMenu.html.twig | 239 + .../src/Resources/views/nodes/add.html.twig | 43 + .../views/nodes/attributes/delete.html.twig | 29 + .../views/nodes/attributes/edit.html.twig | 132 + .../views/nodes/attributes/reset.html.twig | 29 + .../nodes/attributes/translationBar.html.twig | 19 + .../views/nodes/breadcrumb.html.twig | 37 + .../views/nodes/bulkDelete.html.twig | 43 + .../views/nodes/bulkStatus.html.twig | 42 + .../Resources/views/nodes/delete.html.twig | 28 + .../views/nodes/deleteSource.html.twig | 27 + .../src/Resources/views/nodes/edit.html.twig | 113 + .../views/nodes/editAliases.html.twig | 110 + .../views/nodes/editSource.html.twig | 67 + .../Resources/views/nodes/editTags.html.twig | 35 + .../views/nodes/emptyTrash.html.twig | 25 + .../views/nodes/filtersBar.html.twig | 96 + .../Resources/views/nodes/history.html.twig | 30 + .../src/Resources/views/nodes/list.html.twig | 71 + .../Resources/views/nodes/navBack.html.twig | 6 + .../Resources/views/nodes/navBar.html.twig | 76 + .../views/nodes/nodeTypeCircle.html.twig | 15 + .../views/nodes/publishAll.html.twig | 25 + .../Resources/views/nodes/removeTag.html.twig | 25 + .../Resources/views/nodes/translate.html.twig | 42 + .../views/nodes/translationBar.html.twig | 22 + .../views/nodes/translationSEOBar.html.twig | 16 + .../Resources/views/nodes/transtype.html.twig | 28 + .../src/Resources/views/nodes/tree.html.twig | 79 + .../Resources/views/nodes/undelete.html.twig | 28 + .../views/nodes/widgets/node-header.html.twig | 23 + .../views/nodes/widgets/node-row.html.twig | 66 + .../views/panels/admin_menu.html.twig | 80 + .../views/panels/tree_panel.html.twig | 61 + .../views/panels/user_panel.html.twig | 84 + .../views/partials/css-inject-src.html.twig | 6 + .../views/partials/css-inject.html.twig | 8 + .../views/partials/js-inject-src.html.twig | 3 + .../views/partials/js-inject.html.twig | 5 + .../partials/simple-js-inject-src.html.twig | 3 + .../views/partials/simple-js-inject.html.twig | 3 + .../views/redirections/actionsMenu.html.twig | 10 + .../views/redirections/add.html.twig | 38 + .../views/redirections/delete.html.twig | 31 + .../views/redirections/edit.html.twig | 39 + .../views/redirections/editRow.html.twig | 22 + .../views/redirections/list.html.twig | 88 + .../views/roles/actionsMenu.html.twig | 13 + .../src/Resources/views/roles/add.html.twig | 36 + .../Resources/views/roles/delete.html.twig | 27 + .../src/Resources/views/roles/edit.html.twig | 34 + .../Resources/views/roles/import.html.twig | 32 + .../src/Resources/views/roles/list.html.twig | 90 + .../src/Resources/views/search/list.html.twig | 72 + .../views/settingGroups/actionsMenu.html.twig | 8 + .../views/settingGroups/add.html.twig | 35 + .../views/settingGroups/delete.html.twig | 31 + .../views/settingGroups/edit.html.twig | 39 + .../views/settingGroups/list.html.twig | 70 + .../views/settings/actionsMenu.html.twig | 8 + .../Resources/views/settings/add.html.twig | 32 + .../Resources/views/settings/delete.html.twig | 29 + .../Resources/views/settings/edit.html.twig | 32 + .../Resources/views/settings/import.html.twig | 26 + .../Resources/views/settings/list.html.twig | 92 + .../src/Resources/views/simple.html.twig | 50 + .../views/tags/actionsMenu.html.twig | 41 + .../views/tags/add-multiple.html.twig | 32 + .../src/Resources/views/tags/add.html.twig | 33 + .../Resources/views/tags/bulkDelete.html.twig | 45 + .../src/Resources/views/tags/delete.html.twig | 30 + .../src/Resources/views/tags/edit.html.twig | 66 + .../Resources/views/tags/filtersBar.html.twig | 57 + .../src/Resources/views/tags/list.html.twig | 93 + .../Resources/views/tags/navBack.html.twig | 7 + .../src/Resources/views/tags/navBar.html.twig | 25 + .../src/Resources/views/tags/nodes.html.twig | 41 + .../Resources/views/tags/settings.html.twig | 52 + .../views/tags/translationBar.html.twig | 21 + .../src/Resources/views/tags/tree.html.twig | 25 + .../views/translations/actionsMenu.html.twig | 13 + .../views/translations/add.html.twig | 33 + .../views/translations/delete.html.twig | 28 + .../views/translations/edit.html.twig | 33 + .../views/translations/list.html.twig | 81 + .../views/url-aliases/editRow.html.twig | 21 + .../views/users/actionsMenu.html.twig | 13 + .../src/Resources/views/users/add.html.twig | 32 + .../Resources/views/users/delete.html.twig | 28 + .../src/Resources/views/users/edit.html.twig | 48 + .../views/users/editDetails.html.twig | 34 + .../Resources/views/users/groups.html.twig | 56 + .../Resources/views/users/navBar.html.twig | 32 + .../views/users/removeGroup.html.twig | 30 + .../views/users/removeRole.html.twig | 28 + .../src/Resources/views/users/roles.html.twig | 69 + .../Resources/views/users/security.html.twig | 61 + .../views/widgets/backlink.html.twig | 17 + .../views/widgets/countFiltersBar.html.twig | 7 + .../customFormSmallThumbnail.html.twig | 31 + .../widgets/documentSmallThumbnail.html.twig | 72 + .../Resources/views/widgets/drawer.html.twig | 144 + .../views/widgets/filtersBar.html.twig | 33 + .../widgets/folderTree/folderTree.html.twig | 36 + .../widgets/folderTree/singleFolder.html.twig | 59 + .../views/widgets/itemPerPage.html.twig | 22 + .../widgets/nodeSmallThumbnail.html.twig | 45 + .../widgets/nodeTree/contextualMenu.html.twig | 116 + .../widgets/nodeTree/nodeStackTree.html.twig | 27 + .../views/widgets/nodeTree/nodeTree.html.twig | 84 + .../widgets/nodeTree/singleNode.html.twig | 180 + .../widgets/nodesSourcesSearch.html.twig | 48 + .../views/widgets/tagTree/singleTag.html.twig | 65 + .../views/widgets/tagTree/tagTree.html.twig | 35 + .../views/widgets/versionItem.html.twig | 13 + .../src/Resources/webpack/build/base.js | 228 + .../Resources/webpack/build/environments.js | 194 + .../src/Resources/webpack/build/index.js | 24 + .../src/Resources/webpack/config/base.js | 122 + .../Resources/webpack/config/environments.js | 21 + .../src/Resources/webpack/config/index.js | 25 + lib/Rozier/src/RozierApp.php | 140 + lib/Rozier/src/RozierServiceRegistry.php | 120 + .../DocumentThumbnailSerializeSubscriber.php | 55 + lib/Rozier/src/Traits/NodesTrait.php | 168 + .../src/Traits/VersionedControllerTrait.php | 89 + lib/Rozier/src/Utils/SessionListFilters.php | 66 + lib/Rozier/src/Widgets/AbstractWidget.php | 64 + lib/Rozier/src/Widgets/FolderTreeWidget.php | 67 + lib/Rozier/src/Widgets/NodeTreeWidget.php | 295 + lib/Rozier/src/Widgets/TagTreeWidget.php | 141 + lib/Rozier/src/Widgets/TreeWidgetFactory.php | 56 + lib/Rozier/src/bower.json | 36 + lib/Rozier/src/package.json | 134 + .../src/static/assets/fonts/FontAwesome.otf | Bin 0 -> 75188 bytes .../assets/fonts/fontawesome-webfont.eot | Bin 0 -> 72449 bytes .../assets/fonts/fontawesome-webfont.ttf | Bin 0 -> 141564 bytes .../assets/fonts/fontawesome-webfont.woff | Bin 0 -> 83760 bytes .../assets/fonts/fontawesome-webfont.woff2 | Bin 0 -> 56084 bytes .../static/assets/fonts/rz-icons-LICENSE.md | 25 + .../src/static/assets/fonts/rz-icons.eot | Bin 0 -> 25196 bytes .../src/static/assets/fonts/rz-icons.svg | 156 + .../src/static/assets/fonts/rz-icons.ttf | Bin 0 -> 25032 bytes .../src/static/assets/fonts/rz-icons.woff | Bin 0 -> 16764 bytes .../src/static/assets/fonts/rz-icons.woff2 | Bin 0 -> 12288 bytes .../assets/img/apple-touch-icon-114x114.png | Bin 0 -> 2125 bytes .../assets/img/apple-touch-icon-120x120.png | Bin 0 -> 2189 bytes .../assets/img/apple-touch-icon-144x144.png | Bin 0 -> 2607 bytes .../assets/img/apple-touch-icon-152x152.png | Bin 0 -> 2736 bytes .../assets/img/apple-touch-icon-180x180.png | Bin 0 -> 3208 bytes .../assets/img/apple-touch-icon-57x57.png | Bin 0 -> 1220 bytes .../assets/img/apple-touch-icon-60x60.png | Bin 0 -> 1264 bytes .../assets/img/apple-touch-icon-72x72.png | Bin 0 -> 1490 bytes .../assets/img/apple-touch-icon-76x76.png | Bin 0 -> 1543 bytes .../img/apple-touch-icon-precomposed.png | Bin 0 -> 3153 bytes .../static/assets/img/apple-touch-icon.png | Bin 0 -> 3208 bytes .../src/static/assets/img/browserconfig.xml | 12 + .../src/static/assets/img/default_login.jpg | Bin 0 -> 343413 bytes .../src/static/assets/img/favicon-160x160.png | Bin 0 -> 2847 bytes .../src/static/assets/img/favicon-16x16.png | Bin 0 -> 536 bytes .../src/static/assets/img/favicon-192x192.png | Bin 0 -> 3415 bytes .../src/static/assets/img/favicon-32x32.png | Bin 0 -> 797 bytes .../src/static/assets/img/favicon-96x96.png | Bin 0 -> 1812 bytes lib/Rozier/src/static/assets/img/favicon.ico | Bin 0 -> 15086 bytes .../static/assets/img/jquery.minicolors.png | Bin 0 -> 76147 bytes .../ui-bg_diagonals-thick_18_b81900_40x40.png | Bin 0 -> 223 bytes .../ui-bg_diagonals-thick_20_666666_40x40.png | Bin 0 -> 171 bytes .../jqueryui/ui-bg_flat_10_000000_40x100.png | Bin 0 -> 71 bytes .../jqueryui/ui-bg_glass_100_f6f6f6_1x400.png | Bin 0 -> 123 bytes .../jqueryui/ui-bg_glass_100_fdf5ce_1x400.png | Bin 0 -> 193 bytes .../jqueryui/ui-bg_glass_65_ffffff_1x400.png | Bin 0 -> 74 bytes .../ui-bg_gloss-wave_35_f6a828_500x100.png | Bin 0 -> 5115 bytes .../ui-bg_highlight-soft_100_eeeeee_1x100.png | Bin 0 -> 125 bytes .../ui-bg_highlight-soft_75_ffe45c_1x100.png | Bin 0 -> 167 bytes .../img/jqueryui/ui-icons_222222_256x240.png | Bin 0 -> 4262 bytes .../img/jqueryui/ui-icons_228ef1_256x240.png | Bin 0 -> 4261 bytes .../img/jqueryui/ui-icons_ef8c08_256x240.png | Bin 0 -> 4261 bytes .../img/jqueryui/ui-icons_ffd27a_256x240.png | Bin 0 -> 4261 bytes .../img/jqueryui/ui-icons_ffffff_256x240.png | Bin 0 -> 4262 bytes .../src/static/assets/img/map_marker.png | Bin 0 -> 1408 bytes lib/Rozier/src/static/assets/img/marker.png | Bin 0 -> 1100 bytes .../src/static/assets/img/marker@2x.png | Bin 0 -> 2445 bytes .../src/static/assets/img/marker_shadow.png | Bin 0 -> 434 bytes .../static/assets/img/marker_shadow@2x.png | Bin 0 -> 776 bytes .../src/static/assets/img/matrix@2x.png | Bin 0 -> 1341 bytes .../src/static/assets/img/mstile-144x144.png | Bin 0 -> 2499 bytes .../src/static/assets/img/mstile-150x150.png | Bin 0 -> 2551 bytes .../src/static/assets/img/mstile-310x150.png | Bin 0 -> 2769 bytes .../src/static/assets/img/mstile-310x310.png | Bin 0 -> 5365 bytes .../src/static/assets/img/mstile-70x70.png | Bin 0 -> 1777 bytes .../src/static/assets/img/spritemap.png | Bin 0 -> 10208 bytes .../src/static/assets/img/spritemap@2x.png | Bin 0 -> 35675 bytes lib/Rozier/src/static/assets/logo.png | Bin 0 -> 6849 bytes .../static/css/app.3d6bf1608c5a8f3962cd.css | 30 + .../css/app.3d6bf1608c5a8f3962cd.css.map | 1 + .../css/vendor.aaeedaa02e00b12e41cf.css | 10 + .../css/vendor.aaeedaa02e00b12e41cf.css.map | 1 + ...ght.3cb2dde3ccc587cd4b9015acd888d682.woff2 | Bin 0 -> 21212 bytes ...ight.4b78aae656bca1227c451a681682a81d.woff | Bin 0 -> 26568 bytes ...ium.1f2274d646ff39afd4b05eb40736e6d2.woff2 | Bin 0 -> 21164 bytes ...dium.d099b2b90aec499cb3500c79ccd40962.woff | Bin 0 -> 26472 bytes ...ono.04f18efe0897251ef8b8fd05387da836.woff2 | Bin 0 -> 20188 bytes ...Mono.c4fe4f0d09dd98e8e016ebf66f6af85e.woff | Bin 0 -> 25156 bytes ...ular.4ad219c9931fb2a89e21a9a88f7c1ff7.woff | Bin 0 -> 26908 bytes ...lar.78075c1857b3a12055a7d6ef730e66d9.woff2 | Bin 0 -> 21544 bytes ...-400.2ef8ba3410dcc71578a880e7064acd7a.woff | Bin 0 -> 87688 bytes ...400.5e2f92123d241cabecf0b289b9b08d4a.woff2 | Bin 0 -> 74768 bytes ...ont.7efdddc8b19e3b4e3837c28ad9397462.woff2 | Bin 0 -> 56084 bytes ...ont.af7ae505a9eed503f8b8e6982036873e.woff2 | Bin 0 -> 77160 bytes ...bfont.b06871f281fee6b241d60582ae9369b9.ttf | Bin 0 -> 165548 bytes ...font.fdf491ce5ff5b2da02708cd0e9864719.woff | Bin 0 -> 83760 bytes ...font.fee66e712a8a08eef5805a46892932ad.woff | Bin 0 -> 98024 bytes ...ons.68ee2ee734d5585c5aa2b9865de8f93a.woff2 | Bin 0 -> 12264 bytes ...cons.73621ebcb44f3992df7facc158976c0b.woff | Bin 0 -> 15724 bytes .../src/static/img/jquery.minicolors.png | Bin 0 -> 77459 bytes lib/Rozier/src/static/img/spritemap.png | Bin 0 -> 10208 bytes lib/Rozier/src/static/img/spritemap@2x.png | Bin 0 -> 35675 bytes .../src/static/js/app.a8318c6ba29b9defcd69.js | 22 + .../static/js/simple.a8318c6ba29b9defcd69.js | 14 + .../static/js/vendor.a8318c6ba29b9defcd69.js | 280 + .../src/static/vendor/codemirror-rulers.js | 51 + .../src/static/vendor/jquery-2.2.4.min.js | 4 + .../src/static/vendor/jquery-ui.custom.js | 3073 +++++ .../src/static/vendor/jquery.collection.js | 995 ++ lib/Rozier/src/static/vendor/jquery.js | 2 + lib/Rozier/src/static/vendor/jquery.min.js | 2 + .../src/static/vendor/jquery.tag-editor.js | 370 + .../static/vendor/modernizr.custom.50380.js | 4 + lib/Rozier/src/static/vendor/vue.js | 10826 ++++++++++++++++ lib/Rozier/src/static/vendor/vue.min.js | 6 + lib/Rozier/src/static/vendor/vuex.js | 938 ++ lib/Rozier/src/static/vendor/vuex.min.js | 6 + lib/Rozier/src/webpack.config.babel.js | 11 + lib/Rozier/src/yarn.lock | 10593 +++++++++++++++ 2277 files changed, 296523 insertions(+), 15 deletions(-) delete mode 160000 lib/DocGenerator create mode 100644 lib/DocGenerator/.github/workflows/run-test.yml create mode 100644 lib/DocGenerator/.gitignore create mode 100644 lib/DocGenerator/.travis.yml create mode 100644 lib/DocGenerator/CHANGELOG.md create mode 100644 lib/DocGenerator/LICENSE create mode 100644 lib/DocGenerator/Makefile create mode 100644 lib/DocGenerator/README.md create mode 100644 lib/DocGenerator/composer.json create mode 100644 lib/DocGenerator/phpcs.xml.dist create mode 100644 lib/DocGenerator/phpstan.neon create mode 100644 lib/DocGenerator/src/Generators/AbstractFieldGenerator.php create mode 100644 lib/DocGenerator/src/Generators/ChildrenNodeFieldGenerator.php create mode 100644 lib/DocGenerator/src/Generators/CommonFieldGenerator.php create mode 100644 lib/DocGenerator/src/Generators/DefaultValuedFieldGenerator.php create mode 100644 lib/DocGenerator/src/Generators/DocumentationGenerator.php create mode 100644 lib/DocGenerator/src/Generators/MarkdownGeneratorFactory.php create mode 100644 lib/DocGenerator/src/Generators/NodeReferencesFieldGenerator.php create mode 100644 lib/DocGenerator/src/Generators/NodeTypeGenerator.php create mode 100644 lib/DocGenerator/src/Resources/translations/messages.ar.xlf create mode 100644 lib/DocGenerator/src/Resources/translations/messages.de.xlf create mode 100644 lib/DocGenerator/src/Resources/translations/messages.en.xlf create mode 100644 lib/DocGenerator/src/Resources/translations/messages.es.xlf create mode 100644 lib/DocGenerator/src/Resources/translations/messages.fr.xlf create mode 100644 lib/DocGenerator/src/Resources/translations/messages.id.xlf create mode 100644 lib/DocGenerator/src/Resources/translations/messages.it.xlf create mode 100644 lib/DocGenerator/src/Resources/translations/messages.ru.xlf create mode 100644 lib/DocGenerator/src/Resources/translations/messages.sr.xlf create mode 100644 lib/DocGenerator/src/Resources/translations/messages.tr.xlf create mode 100644 lib/DocGenerator/src/Resources/translations/messages.uk.xlf create mode 100644 lib/DocGenerator/src/Resources/translations/messages.xlf create mode 100644 lib/DocGenerator/src/Resources/translations/messages.zh.xlf delete mode 160000 lib/Documents create mode 100644 lib/Documents/.editorconfig create mode 100644 lib/Documents/.github/workflows/run-test.yml create mode 100644 lib/Documents/.gitignore create mode 100644 lib/Documents/.travis.yml create mode 100644 lib/Documents/CHANGELOG.md create mode 100644 lib/Documents/LICENSE.md create mode 100644 lib/Documents/Makefile create mode 100644 lib/Documents/README.md create mode 100644 lib/Documents/composer.json create mode 100644 lib/Documents/docker-compose.yml create mode 100644 lib/Documents/files/folder/file.svg create mode 100644 lib/Documents/phpcs.xml.dist create mode 100644 lib/Documents/phpstan.neon create mode 100644 lib/Documents/src/AbstractDocumentFactory.php create mode 100644 lib/Documents/src/AbstractDocumentFinder.php create mode 100644 lib/Documents/src/ArrayDocumentFinder.php create mode 100644 lib/Documents/src/AverageColorResolver.php create mode 100644 lib/Documents/src/Console/AbstractDocumentCommand.php create mode 100644 lib/Documents/src/Console/DocumentAverageColorCommand.php create mode 100644 lib/Documents/src/Console/DocumentClearFolderCommand.php create mode 100644 lib/Documents/src/Console/DocumentDownscaleCommand.php create mode 100644 lib/Documents/src/Console/DocumentDuplicatesCommand.php create mode 100644 lib/Documents/src/Console/DocumentFileHashCommand.php create mode 100644 lib/Documents/src/Console/DocumentFilesizeCommand.php create mode 100644 lib/Documents/src/Console/DocumentPruneCommand.php create mode 100644 lib/Documents/src/Console/DocumentPruneOrphansCommand.php create mode 100644 lib/Documents/src/Console/DocumentSizeCommand.php create mode 100644 lib/Documents/src/DocumentArchiver.php create mode 100644 lib/Documents/src/DocumentFinderInterface.php create mode 100644 lib/Documents/src/DownloadedFile.php create mode 100644 lib/Documents/src/DownscaleImageManager.php create mode 100644 lib/Documents/src/Events/CachePurgeAssetsRequestEvent.php create mode 100644 lib/Documents/src/Events/DocumentCreatedEvent.php create mode 100644 lib/Documents/src/Events/DocumentDeletedEvent.php create mode 100644 lib/Documents/src/Events/DocumentFileUpdatedEvent.php create mode 100644 lib/Documents/src/Events/DocumentInFolderEvent.php create mode 100644 lib/Documents/src/Events/DocumentLifeCycleSubscriber.php create mode 100644 lib/Documents/src/Events/DocumentOutFolderEvent.php create mode 100644 lib/Documents/src/Events/DocumentUpdatedEvent.php create mode 100644 lib/Documents/src/Events/FilterDocumentEvent.php create mode 100644 lib/Documents/src/Exceptions/APINeedsAuthentificationException.php create mode 100644 lib/Documents/src/Exceptions/DocumentWithoutFileException.php create mode 100644 lib/Documents/src/Exceptions/EmbedDocumentAlreadyExistsException.php create mode 100644 lib/Documents/src/Exceptions/InvalidEmbedId.php create mode 100644 lib/Documents/src/MediaFinders/AbstractDailymotionEmbedFinder.php create mode 100644 lib/Documents/src/MediaFinders/AbstractDeezerEmbedFinder.php create mode 100644 lib/Documents/src/MediaFinders/AbstractEmbedFinder.php create mode 100644 lib/Documents/src/MediaFinders/AbstractMixcloudEmbedFinder.php create mode 100644 lib/Documents/src/MediaFinders/AbstractPodcastFinder.php create mode 100644 lib/Documents/src/MediaFinders/AbstractSoundcloudEmbedFinder.php create mode 100644 lib/Documents/src/MediaFinders/AbstractSpotifyEmbedFinder.php create mode 100644 lib/Documents/src/MediaFinders/AbstractTedEmbedFinder.php create mode 100644 lib/Documents/src/MediaFinders/AbstractTwitchEmbedFinder.php create mode 100644 lib/Documents/src/MediaFinders/AbstractUnsplashPictureFinder.php create mode 100644 lib/Documents/src/MediaFinders/AbstractVimeoEmbedFinder.php create mode 100644 lib/Documents/src/MediaFinders/AbstractYoutubeEmbedFinder.php create mode 100644 lib/Documents/src/MediaFinders/EmbedFinderFactory.php create mode 100644 lib/Documents/src/MediaFinders/EmbedFinderInterface.php create mode 100644 lib/Documents/src/MediaFinders/FacebookPictureFinder.php create mode 100644 lib/Documents/src/MediaFinders/RandomImageFinder.php create mode 100644 lib/Documents/src/Models/AdvancedDocumentInterface.php create mode 100644 lib/Documents/src/Models/DisplayableInterface.php create mode 100644 lib/Documents/src/Models/DocumentInterface.php create mode 100644 lib/Documents/src/Models/DocumentTrait.php create mode 100644 lib/Documents/src/Models/FileAwareInterface.php create mode 100644 lib/Documents/src/Models/FileHashInterface.php create mode 100644 lib/Documents/src/Models/FolderInterface.php create mode 100644 lib/Documents/src/Models/HasThumbnailInterface.php create mode 100644 lib/Documents/src/Models/SimpleDocument.php create mode 100644 lib/Documents/src/Models/SimpleFileAware.php create mode 100644 lib/Documents/src/Models/SizeableInterface.php create mode 100644 lib/Documents/src/Models/TimeableInterface.php create mode 100644 lib/Documents/src/OptionsResolver/UrlOptionsResolver.php create mode 100644 lib/Documents/src/OptionsResolver/ViewOptionsResolver.php create mode 100644 lib/Documents/src/Packages.php create mode 100644 lib/Documents/src/Renderer/AbstractImageRenderer.php create mode 100644 lib/Documents/src/Renderer/AbstractRenderer.php create mode 100644 lib/Documents/src/Renderer/AudioRenderer.php create mode 100644 lib/Documents/src/Renderer/ChainRenderer.php create mode 100644 lib/Documents/src/Renderer/EmbedRenderer.php create mode 100644 lib/Documents/src/Renderer/ImageRenderer.php create mode 100644 lib/Documents/src/Renderer/InlineSvgRenderer.php create mode 100644 lib/Documents/src/Renderer/PdfRenderer.php create mode 100644 lib/Documents/src/Renderer/PictureRenderer.php create mode 100644 lib/Documents/src/Renderer/RendererInterface.php create mode 100644 lib/Documents/src/Renderer/SvgRenderer.php create mode 100644 lib/Documents/src/Renderer/ThumbnailRenderer.php create mode 100644 lib/Documents/src/Renderer/VideoRenderer.php create mode 100644 lib/Documents/src/Repository/DocumentRepositoryInterface.php create mode 100644 lib/Documents/src/Resources/views/documents/audio.html.twig create mode 100644 lib/Documents/src/Resources/views/documents/image.html.twig create mode 100644 lib/Documents/src/Resources/views/documents/pdf.html.twig create mode 100644 lib/Documents/src/Resources/views/documents/picture-inner.html.twig create mode 100644 lib/Documents/src/Resources/views/documents/picture-source.html.twig create mode 100644 lib/Documents/src/Resources/views/documents/picture.html.twig create mode 100644 lib/Documents/src/Resources/views/documents/video.html.twig create mode 100644 lib/Documents/src/SvgSizeResolver.php create mode 100644 lib/Documents/src/TwigExtension/DocumentExtension.php create mode 100644 lib/Documents/src/UrlGenerators/AbstractDocumentUrlGenerator.php create mode 100644 lib/Documents/src/UrlGenerators/DocumentUrlGeneratorInterface.php create mode 100644 lib/Documents/src/UrlGenerators/DummyDocumentUrlGenerator.php create mode 100644 lib/Documents/src/UrlGenerators/OptionsCompiler.php create mode 100644 lib/Documents/src/Viewers/SvgDocumentViewer.php create mode 100644 lib/Documents/tests/Roadiz/Document/Renderer/AudioRenderer.php create mode 100644 lib/Documents/tests/Roadiz/Document/Renderer/ChainRenderer.php create mode 100644 lib/Documents/tests/Roadiz/Document/Renderer/EmbedRenderer.php create mode 100644 lib/Documents/tests/Roadiz/Document/Renderer/ImageRenderer.php create mode 100644 lib/Documents/tests/Roadiz/Document/Renderer/InlineSvgRenderer.php create mode 100644 lib/Documents/tests/Roadiz/Document/Renderer/PdfRenderer.php create mode 100644 lib/Documents/tests/Roadiz/Document/Renderer/PictureRenderer.php create mode 100644 lib/Documents/tests/Roadiz/Document/Renderer/SvgRenderer.php create mode 100644 lib/Documents/tests/Roadiz/Document/Renderer/VideoRenderer.php delete mode 160000 lib/DtsGenerator create mode 100644 lib/DtsGenerator/.github/workflows/run-test.yml create mode 100644 lib/DtsGenerator/.gitignore create mode 100644 lib/DtsGenerator/.travis.yml create mode 100644 lib/DtsGenerator/Makefile create mode 100644 lib/DtsGenerator/README.md create mode 100644 lib/DtsGenerator/composer.json create mode 100644 lib/DtsGenerator/phpcs.xml.dist create mode 100644 lib/DtsGenerator/phpstan.neon create mode 100644 lib/DtsGenerator/src/DeclarationGeneratorFactory.php create mode 100644 lib/DtsGenerator/src/Generators/AbstractFieldGenerator.php create mode 100644 lib/DtsGenerator/src/Generators/ChildrenNodeFieldGenerator.php create mode 100644 lib/DtsGenerator/src/Generators/DeclarationGenerator.php create mode 100644 lib/DtsGenerator/src/Generators/DocumentsFieldGenerator.php create mode 100644 lib/DtsGenerator/src/Generators/EnumFieldGenerator.php create mode 100644 lib/DtsGenerator/src/Generators/NodeReferencesFieldGenerator.php create mode 100644 lib/DtsGenerator/src/Generators/NodeTypeGenerator.php create mode 100644 lib/DtsGenerator/src/Generators/ScalarFieldGenerator.php delete mode 160000 lib/EntityGenerator create mode 100644 lib/EntityGenerator/.editorconfig create mode 100644 lib/EntityGenerator/.github/workflows/run-test.yml create mode 100644 lib/EntityGenerator/.gitignore create mode 100644 lib/EntityGenerator/.travis.yml create mode 100644 lib/EntityGenerator/CHANGELOG.md create mode 100644 lib/EntityGenerator/LICENSE create mode 100644 lib/EntityGenerator/Makefile create mode 100644 lib/EntityGenerator/README.md create mode 100644 lib/EntityGenerator/composer.json create mode 100644 lib/EntityGenerator/phpstan.neon create mode 100644 lib/EntityGenerator/src/Attribute/AttributeGenerator.php create mode 100644 lib/EntityGenerator/src/Attribute/AttributeListGenerator.php create mode 100644 lib/EntityGenerator/src/ClassGeneratorInterface.php create mode 100644 lib/EntityGenerator/src/EntityGenerator.php create mode 100644 lib/EntityGenerator/src/EntityGeneratorFactory.php create mode 100644 lib/EntityGenerator/src/EntityGeneratorInterface.php create mode 100644 lib/EntityGenerator/src/Field/AbstractConfigurableFieldGenerator.php create mode 100644 lib/EntityGenerator/src/Field/AbstractFieldGenerator.php create mode 100644 lib/EntityGenerator/src/Field/CollectionFieldGenerator.php create mode 100644 lib/EntityGenerator/src/Field/CustomFormsFieldGenerator.php create mode 100644 lib/EntityGenerator/src/Field/DocumentsFieldGenerator.php create mode 100644 lib/EntityGenerator/src/Field/ManyToManyFieldGenerator.php create mode 100644 lib/EntityGenerator/src/Field/ManyToOneFieldGenerator.php create mode 100644 lib/EntityGenerator/src/Field/NodesFieldGenerator.php create mode 100644 lib/EntityGenerator/src/Field/NonVirtualFieldGenerator.php create mode 100644 lib/EntityGenerator/src/Field/ProxiedManyToManyFieldGenerator.php create mode 100644 lib/EntityGenerator/src/Field/YamlFieldGenerator.php create mode 100644 lib/EntityGenerator/src/RepositoryGenerator.php create mode 100644 lib/EntityGenerator/src/RepositoryGeneratorInterface.php create mode 100644 lib/EntityGenerator/tests/mocks/GeneratedNodesSources/NSMock.php create mode 100644 lib/EntityGenerator/tests/mocks/GeneratedNodesSourcesWithRepository/NSMock.php create mode 100644 lib/EntityGenerator/tests/mocks/GeneratedNodesSourcesWithRepository/NSMockRepository.php create mode 100644 lib/EntityGenerator/tests/mocks/NodeTypeAwareTrait.php create mode 100644 lib/EntityGenerator/tests/mocks/NodeTypeField.php create mode 100644 lib/EntityGenerator/tests/units/EntityGenerator.php create mode 100644 lib/EntityGenerator/tests/units/EntityGeneratorFactory.php delete mode 160000 lib/Jwt create mode 100644 lib/Jwt/.github/workflows/run-test.yml create mode 100644 lib/Jwt/.gitignore create mode 100644 lib/Jwt/.travis.yml create mode 100644 lib/Jwt/LICENSE create mode 100644 lib/Jwt/Makefile create mode 100644 lib/Jwt/README.md create mode 100644 lib/Jwt/composer.json create mode 100644 lib/Jwt/phpcs.xml.dist create mode 100644 lib/Jwt/phpstan.neon create mode 100644 lib/Jwt/src/JwtConfigurationFactory.php create mode 100644 lib/Jwt/src/Validation/Constraint/HostedDomain.php create mode 100644 lib/Jwt/src/Validation/Constraint/UserInfoEndpoint.php delete mode 160000 lib/Markdown create mode 100644 lib/Markdown/.editorconfig create mode 100644 lib/Markdown/.github/workflows/run-test.yml create mode 100644 lib/Markdown/.gitignore create mode 100644 lib/Markdown/.travis.yml create mode 100644 lib/Markdown/CHANGELOG.md create mode 100644 lib/Markdown/Makefile create mode 100644 lib/Markdown/README.md create mode 100644 lib/Markdown/composer.json create mode 100644 lib/Markdown/docker-compose.yml create mode 100644 lib/Markdown/phpcs.xml.dist create mode 100644 lib/Markdown/phpstan.neon create mode 100644 lib/Markdown/src/CommonMark.php create mode 100644 lib/Markdown/src/MarkdownInterface.php create mode 100644 lib/Markdown/src/Twig/MarkdownExtension.php delete mode 160000 lib/Models create mode 100644 lib/Models/.editorconfig create mode 100644 lib/Models/.github/workflows/run-test.yml create mode 100644 lib/Models/.gitignore create mode 100644 lib/Models/.travis.yml create mode 100644 lib/Models/CHANGELOG.md create mode 100644 lib/Models/LICENSE.md create mode 100644 lib/Models/Makefile create mode 100644 lib/Models/README.md create mode 100644 lib/Models/composer.json create mode 100644 lib/Models/phpcs.xml.dist create mode 100644 lib/Models/phpstan.neon create mode 100644 lib/Models/src/Roadiz/Bag/LazyParameterBag.php create mode 100644 lib/Models/src/Roadiz/Core/AbstractEntities/AbstractDateTimed.php create mode 100644 lib/Models/src/Roadiz/Core/AbstractEntities/AbstractDateTimedPositioned.php create mode 100644 lib/Models/src/Roadiz/Core/AbstractEntities/AbstractEntity.php create mode 100644 lib/Models/src/Roadiz/Core/AbstractEntities/AbstractField.php create mode 100644 lib/Models/src/Roadiz/Core/AbstractEntities/AbstractHuman.php create mode 100644 lib/Models/src/Roadiz/Core/AbstractEntities/AbstractPositioned.php create mode 100644 lib/Models/src/Roadiz/Core/AbstractEntities/LeafInterface.php create mode 100644 lib/Models/src/Roadiz/Core/AbstractEntities/LeafTrait.php create mode 100644 lib/Models/src/Roadiz/Core/AbstractEntities/PersistableInterface.php create mode 100644 lib/Models/src/Roadiz/Core/AbstractEntities/PositionedInterface.php create mode 100644 lib/Models/src/Roadiz/Core/AbstractEntities/PositionedTrait.php create mode 100644 lib/Models/src/Roadiz/Core/AbstractEntities/TranslationInterface.php create mode 100644 lib/Models/src/Roadiz/Core/Events/LeafEntityLifeCycleSubscriber.php create mode 100644 lib/Models/src/Roadiz/Core/Handlers/AbstractHandler.php create mode 100644 lib/Models/src/Roadiz/Core/Handlers/HandlerFactoryInterface.php create mode 100644 lib/Models/src/Roadiz/Utils/StringHandler.php create mode 100644 lib/Models/tests/Utils/StringHandlerTest.php create mode 100644 lib/Models/tests/bootstrap.php delete mode 160000 lib/OpenId create mode 100644 lib/OpenId/.editorconfig create mode 100644 lib/OpenId/.github/workflows/run-test.yml create mode 100644 lib/OpenId/.gitignore create mode 100644 lib/OpenId/.travis.yml create mode 100644 lib/OpenId/CHANGELOG.md create mode 100644 lib/OpenId/LICENSE create mode 100644 lib/OpenId/Makefile create mode 100644 lib/OpenId/README.md create mode 100644 lib/OpenId/composer.json create mode 100644 lib/OpenId/phpcs.xml.dist create mode 100644 lib/OpenId/phpstan.neon create mode 100644 lib/OpenId/src/Authentication/OpenIdAuthenticator.php create mode 100644 lib/OpenId/src/Authentication/Provider/ChainJwtRoleStrategy.php create mode 100644 lib/OpenId/src/Authentication/Provider/JwtRoleStrategy.php create mode 100644 lib/OpenId/src/Authentication/Provider/OpenIdAccountProvider.php create mode 100644 lib/OpenId/src/Authentication/Provider/SettingsRoleStrategy.php create mode 100644 lib/OpenId/src/Discovery.php create mode 100644 lib/OpenId/src/Exception/DiscoveryNotAvailableException.php create mode 100644 lib/OpenId/src/Exception/OpenIdAuthenticationException.php create mode 100644 lib/OpenId/src/Exception/OpenIdConfigurationException.php create mode 100644 lib/OpenId/src/OAuth2LinkGenerator.php create mode 100644 lib/OpenId/src/OpenIdJwtConfigurationFactory.php create mode 100644 lib/OpenId/src/User/OpenIdAccount.php delete mode 160000 lib/Random create mode 100644 lib/Random/.github/workflows/run-test.yml create mode 100644 lib/Random/.gitignore create mode 100644 lib/Random/.travis.yml create mode 100644 lib/Random/LICENSE create mode 100644 lib/Random/Makefile create mode 100644 lib/Random/README.md create mode 100644 lib/Random/composer.json create mode 100644 lib/Random/phpcs.xml.dist create mode 100644 lib/Random/phpstan.neon create mode 100644 lib/Random/src/PasswordGenerator.php create mode 100644 lib/Random/src/PasswordGeneratorInterface.php create mode 100644 lib/Random/src/RandomGenerator.php create mode 100644 lib/Random/src/SaltGenerator.php create mode 100644 lib/Random/src/SaltGeneratorInterface.php create mode 100644 lib/Random/src/TokenGenerator.php create mode 100644 lib/Random/src/TokenGeneratorInterface.php delete mode 160000 lib/RoadizCompatBundle create mode 100644 lib/RoadizCompatBundle/.github/workflows/run-test.yml create mode 100644 lib/RoadizCompatBundle/.gitignore create mode 100644 lib/RoadizCompatBundle/.travis.yml create mode 100644 lib/RoadizCompatBundle/CHANGELOG.md create mode 100644 lib/RoadizCompatBundle/LICENSE.md create mode 100644 lib/RoadizCompatBundle/Makefile create mode 100644 lib/RoadizCompatBundle/README.md create mode 100644 lib/RoadizCompatBundle/composer.json create mode 100644 lib/RoadizCompatBundle/config/packages/roadiz_compat.yaml create mode 100644 lib/RoadizCompatBundle/config/services.yaml create mode 100644 lib/RoadizCompatBundle/deprecated.php create mode 100644 lib/RoadizCompatBundle/phpcs.xml.dist create mode 100644 lib/RoadizCompatBundle/phpstan.neon create mode 100644 lib/RoadizCompatBundle/src/Aliases.php create mode 100644 lib/RoadizCompatBundle/src/Console/ThemeAssetsCommand.php create mode 100644 lib/RoadizCompatBundle/src/Console/ThemeGenerateCommand.php create mode 100644 lib/RoadizCompatBundle/src/Console/ThemeInfoCommand.php create mode 100644 lib/RoadizCompatBundle/src/Console/ThemeInstallCommand.php create mode 100644 lib/RoadizCompatBundle/src/Console/ThemeMigrateCommand.php create mode 100644 lib/RoadizCompatBundle/src/Console/ThemesListCommand.php create mode 100644 lib/RoadizCompatBundle/src/Controller/AppController.php create mode 100644 lib/RoadizCompatBundle/src/Controller/Controller.php create mode 100644 lib/RoadizCompatBundle/src/Controller/FrontendController.php create mode 100644 lib/RoadizCompatBundle/src/DependencyInjection/Compiler/ThemesTranslatorPathsCompilerPass.php create mode 100644 lib/RoadizCompatBundle/src/DependencyInjection/Configuration.php create mode 100644 lib/RoadizCompatBundle/src/DependencyInjection/RoadizCompatExtension.php create mode 100644 lib/RoadizCompatBundle/src/EventSubscriber/ControllerEventSubscriber.php create mode 100644 lib/RoadizCompatBundle/src/EventSubscriber/ExceptionSubscriber.php create mode 100644 lib/RoadizCompatBundle/src/EventSubscriber/MaintenanceModeSubscriber.php create mode 100644 lib/RoadizCompatBundle/src/RoadizCompatBundle.php create mode 100644 lib/RoadizCompatBundle/src/Routing/ThemeAwareNodeRouter.php create mode 100644 lib/RoadizCompatBundle/src/Routing/ThemeAwareNodeUrlMatcher.php create mode 100644 lib/RoadizCompatBundle/src/Routing/ThemeRoutesLoader.php create mode 100644 lib/RoadizCompatBundle/src/Theme/StaticThemeResolver.php create mode 100644 lib/RoadizCompatBundle/src/Theme/ThemeGenerator.php create mode 100644 lib/RoadizCompatBundle/src/Theme/ThemeInfo.php create mode 100644 lib/RoadizCompatBundle/src/Theme/ThemeResolverInterface.php delete mode 160000 lib/RoadizCoreBundle create mode 100644 lib/RoadizCoreBundle/.github/workflows/run-test.yml create mode 100644 lib/RoadizCoreBundle/.gitignore create mode 100644 lib/RoadizCoreBundle/.travis.yml create mode 100644 lib/RoadizCoreBundle/CHANGELOG.md create mode 100644 lib/RoadizCoreBundle/LICENSE.md create mode 100644 lib/RoadizCoreBundle/Makefile create mode 100644 lib/RoadizCoreBundle/README.md create mode 100644 lib/RoadizCoreBundle/composer.json create mode 100644 lib/RoadizCoreBundle/config/api_resources/attribute.yml create mode 100644 lib/RoadizCoreBundle/config/api_resources/attribute_value.yml create mode 100644 lib/RoadizCoreBundle/config/api_resources/custom_form.yml create mode 100644 lib/RoadizCoreBundle/config/api_resources/document.yml create mode 100644 lib/RoadizCoreBundle/config/api_resources/folder.yml create mode 100644 lib/RoadizCoreBundle/config/api_resources/node.yml create mode 100644 lib/RoadizCoreBundle/config/api_resources/nodes_sources.yml create mode 100644 lib/RoadizCoreBundle/config/api_resources/realm.yml create mode 100644 lib/RoadizCoreBundle/config/api_resources/tag.yml create mode 100644 lib/RoadizCoreBundle/config/api_resources/translation.yml create mode 100644 lib/RoadizCoreBundle/config/api_resources/web_response.yml create mode 100644 lib/RoadizCoreBundle/config/fixtures.yaml create mode 100644 lib/RoadizCoreBundle/config/fixtures/roles.json create mode 100644 lib/RoadizCoreBundle/config/fixtures/settings.json create mode 100644 lib/RoadizCoreBundle/config/packages/api_platform.yaml create mode 100644 lib/RoadizCoreBundle/config/packages/doctrine.yaml create mode 100644 lib/RoadizCoreBundle/config/packages/flysystem.yaml create mode 100644 lib/RoadizCoreBundle/config/packages/framework.yaml create mode 100644 lib/RoadizCoreBundle/config/packages/jms_serializer.yaml create mode 100644 lib/RoadizCoreBundle/config/packages/messenger.yaml create mode 100644 lib/RoadizCoreBundle/config/packages/monolog.yaml create mode 100644 lib/RoadizCoreBundle/config/packages/roadiz_core.yaml create mode 100644 lib/RoadizCoreBundle/config/packages/security.yaml create mode 100644 lib/RoadizCoreBundle/config/packages/twig.yaml create mode 100644 lib/RoadizCoreBundle/config/routing.yaml create mode 100644 lib/RoadizCoreBundle/config/services.yaml create mode 100644 lib/RoadizCoreBundle/crowdin.yml create mode 100644 lib/RoadizCoreBundle/css/README.md create mode 100644 lib/RoadizCoreBundle/css/transactionalStyles.css create mode 100644 lib/RoadizCoreBundle/manifest.json create mode 100644 lib/RoadizCoreBundle/migrations/Version20201203004857.php create mode 100644 lib/RoadizCoreBundle/migrations/Version20201214232628.php create mode 100644 lib/RoadizCoreBundle/migrations/Version20201225181256.php create mode 100644 lib/RoadizCoreBundle/migrations/Version20210423072744.php create mode 100644 lib/RoadizCoreBundle/migrations/Version20210423161606.php create mode 100644 lib/RoadizCoreBundle/migrations/Version20210423164248.php create mode 100644 lib/RoadizCoreBundle/migrations/Version20210506085247.php create mode 100644 lib/RoadizCoreBundle/migrations/Version20210520092543.php create mode 100644 lib/RoadizCoreBundle/migrations/Version20210527131435.php create mode 100644 lib/RoadizCoreBundle/migrations/Version20210701151713.php create mode 100644 lib/RoadizCoreBundle/migrations/Version20210715120118.php create mode 100644 lib/RoadizCoreBundle/migrations/Version20210802132310.php create mode 100644 lib/RoadizCoreBundle/migrations/Version20220104132107.php create mode 100644 lib/RoadizCoreBundle/migrations/Version20220204180955.php create mode 100644 lib/RoadizCoreBundle/migrations/Version20220525131842.php create mode 100644 lib/RoadizCoreBundle/migrations/Version20220525150545.php create mode 100644 lib/RoadizCoreBundle/migrations/Version20220530112008.php create mode 100644 lib/RoadizCoreBundle/migrations/Version20220530132117.php create mode 100644 lib/RoadizCoreBundle/migrations/Version20220602173719.php create mode 100644 lib/RoadizCoreBundle/migrations/Version20220622141902.php create mode 100644 lib/RoadizCoreBundle/migrations/Version20220623133037.php create mode 100644 lib/RoadizCoreBundle/migrations/Version20220729100037.php create mode 100644 lib/RoadizCoreBundle/migrations/Version20220901082425.php create mode 100644 lib/RoadizCoreBundle/migrations/Version20221007085729.php create mode 100644 lib/RoadizCoreBundle/migrations/Version20221018195433.php create mode 100644 lib/RoadizCoreBundle/migrations/Version20221220181250.php create mode 100644 lib/RoadizCoreBundle/migrations/Version20230125105107.php create mode 100644 lib/RoadizCoreBundle/phpcs.xml.dist create mode 100644 lib/RoadizCoreBundle/phpstan.neon create mode 100644 lib/RoadizCoreBundle/public/assets/.gitkeep create mode 100644 lib/RoadizCoreBundle/public/assets/roadiz_white.jpg create mode 100644 lib/RoadizCoreBundle/public/files/.gitkeep create mode 100644 lib/RoadizCoreBundle/src/Api/Breadcrumbs/Breadcrumbs.php create mode 100644 lib/RoadizCoreBundle/src/Api/Breadcrumbs/BreadcrumbsFactoryInterface.php create mode 100644 lib/RoadizCoreBundle/src/Api/Breadcrumbs/BreadcrumbsInterface.php create mode 100644 lib/RoadizCoreBundle/src/Api/Breadcrumbs/NodesSourcesBreadcrumbsFactory.php create mode 100644 lib/RoadizCoreBundle/src/Api/Controller/GetWebResponseByPathController.php create mode 100644 lib/RoadizCoreBundle/src/Api/Controller/NodesSourcesSearchController.php create mode 100644 lib/RoadizCoreBundle/src/Api/Controller/TranslationAwareControllerTrait.php create mode 100644 lib/RoadizCoreBundle/src/Api/DataTransformer/AttributeOutputDataTransformer.php create mode 100644 lib/RoadizCoreBundle/src/Api/DataTransformer/AttributeValueOutputDataTransformer.php create mode 100644 lib/RoadizCoreBundle/src/Api/DataTransformer/BaseNodesSourcesOutputDataTransformer.php create mode 100644 lib/RoadizCoreBundle/src/Api/DataTransformer/BlocksAwareWebResponseOutputDataTransformerTrait.php create mode 100644 lib/RoadizCoreBundle/src/Api/DataTransformer/CustomFormOutputDataTransformer.php create mode 100644 lib/RoadizCoreBundle/src/Api/DataTransformer/DocumentOutputDataTransformer.php create mode 100644 lib/RoadizCoreBundle/src/Api/DataTransformer/FolderOutputDataTransformer.php create mode 100644 lib/RoadizCoreBundle/src/Api/DataTransformer/NodeOutputDataTransformer.php create mode 100644 lib/RoadizCoreBundle/src/Api/DataTransformer/NodesSourcesOutputDataTransformer.php create mode 100644 lib/RoadizCoreBundle/src/Api/DataTransformer/RealmsAwareWebResponseOutputDataTransformerTrait.php create mode 100644 lib/RoadizCoreBundle/src/Api/DataTransformer/TagOutputDataTransformer.php create mode 100644 lib/RoadizCoreBundle/src/Api/DataTransformer/TranslationOutputDataTransformer.php create mode 100644 lib/RoadizCoreBundle/src/Api/DataTransformer/WebResponseDataTransformerInterface.php create mode 100644 lib/RoadizCoreBundle/src/Api/DataTransformer/WebResponseOutputDataTransformer.php create mode 100644 lib/RoadizCoreBundle/src/Api/Dto/Archive.php create mode 100644 lib/RoadizCoreBundle/src/Api/Dto/AttributeOutput.php create mode 100644 lib/RoadizCoreBundle/src/Api/Dto/AttributeValueOutput.php create mode 100644 lib/RoadizCoreBundle/src/Api/Dto/CustomFormOutput.php create mode 100644 lib/RoadizCoreBundle/src/Api/Dto/DocumentOutput.php create mode 100644 lib/RoadizCoreBundle/src/Api/Dto/FolderOutput.php create mode 100644 lib/RoadizCoreBundle/src/Api/Dto/NodeOutput.php create mode 100644 lib/RoadizCoreBundle/src/Api/Dto/NodesSourcesDto.php create mode 100644 lib/RoadizCoreBundle/src/Api/Dto/NodesSourcesOutput.php create mode 100644 lib/RoadizCoreBundle/src/Api/Dto/TagOutput.php create mode 100644 lib/RoadizCoreBundle/src/Api/Dto/TranslationOutput.php create mode 100644 lib/RoadizCoreBundle/src/Api/Extension/ArchiveExtension.php create mode 100644 lib/RoadizCoreBundle/src/Api/Extension/DocumentQueryExtension.php create mode 100644 lib/RoadizCoreBundle/src/Api/Extension/NodeQueryExtension.php create mode 100644 lib/RoadizCoreBundle/src/Api/Extension/NodesSourcesQueryExtension.php create mode 100644 lib/RoadizCoreBundle/src/Api/Filter/ArchiveFilter.php create mode 100644 lib/RoadizCoreBundle/src/Api/Filter/CopyrightValidFilter.php create mode 100644 lib/RoadizCoreBundle/src/Api/Filter/GeneratedEntityFilter.php create mode 100644 lib/RoadizCoreBundle/src/Api/Filter/IntersectionFilter.php create mode 100644 lib/RoadizCoreBundle/src/Api/Filter/LocaleFilter.php create mode 100644 lib/RoadizCoreBundle/src/Api/Filter/NotFilter.php create mode 100644 lib/RoadizCoreBundle/src/Api/ListManager/SolrPaginator.php create mode 100644 lib/RoadizCoreBundle/src/Api/ListManager/SolrSearchListManager.php create mode 100644 lib/RoadizCoreBundle/src/Api/Model/BlocksAwareWebResponseInterface.php create mode 100644 lib/RoadizCoreBundle/src/Api/Model/NodesSourcesHead.php create mode 100644 lib/RoadizCoreBundle/src/Api/Model/NodesSourcesHeadFactory.php create mode 100644 lib/RoadizCoreBundle/src/Api/Model/NodesSourcesHeadInterface.php create mode 100644 lib/RoadizCoreBundle/src/Api/Model/RealmsAwareWebResponseInterface.php create mode 100644 lib/RoadizCoreBundle/src/Api/Model/WebResponse.php create mode 100644 lib/RoadizCoreBundle/src/Api/Model/WebResponseInterface.php create mode 100644 lib/RoadizCoreBundle/src/Api/OpenApi/JwtDecorator.php create mode 100644 lib/RoadizCoreBundle/src/Api/TreeWalker/AutoChildrenNodeSourceWalker.php create mode 100644 lib/RoadizCoreBundle/src/Api/TreeWalker/Definition/MultiTypeChildrenDefinition.php create mode 100644 lib/RoadizCoreBundle/src/Api/TreeWalker/Definition/NonReachableNodeSourceBlockDefinition.php create mode 100644 lib/RoadizCoreBundle/src/Api/TreeWalker/Definition/ReachableNodeSourceDefinition.php create mode 100644 lib/RoadizCoreBundle/src/Api/TreeWalker/NodeSourceWalkerContext.php create mode 100644 lib/RoadizCoreBundle/src/Api/TreeWalker/NodeSourceWalkerContextFactory.php create mode 100644 lib/RoadizCoreBundle/src/Api/TreeWalker/TreeWalkerGenerator.php create mode 100644 lib/RoadizCoreBundle/src/Api/TreeWalker/WalkerContextFactoryInterface.php create mode 100644 lib/RoadizCoreBundle/src/Bag/NodeTypes.php create mode 100644 lib/RoadizCoreBundle/src/Bag/Roles.php create mode 100644 lib/RoadizCoreBundle/src/Bag/Settings.php create mode 100644 lib/RoadizCoreBundle/src/Cache/Clearer/AssetsFileClearer.php create mode 100644 lib/RoadizCoreBundle/src/Cache/Clearer/ClearerInterface.php create mode 100644 lib/RoadizCoreBundle/src/Cache/Clearer/FileClearer.php create mode 100644 lib/RoadizCoreBundle/src/Cache/Clearer/NodesSourcesUrlsCacheClearer.php create mode 100644 lib/RoadizCoreBundle/src/Cache/Clearer/OPCacheClearer.php create mode 100644 lib/RoadizCoreBundle/src/Cache/CloudflareProxyCache.php create mode 100644 lib/RoadizCoreBundle/src/Cache/ReverseProxyCache.php create mode 100644 lib/RoadizCoreBundle/src/Cache/ReverseProxyCacheLocator.php create mode 100644 lib/RoadizCoreBundle/src/Configuration/CollectionFieldConfiguration.php create mode 100644 lib/RoadizCoreBundle/src/Configuration/JoinNodeTypeFieldConfiguration.php create mode 100644 lib/RoadizCoreBundle/src/Configuration/ProviderFieldConfiguration.php create mode 100644 lib/RoadizCoreBundle/src/Console/CleanLoginAttemptCommand.php create mode 100644 lib/RoadizCoreBundle/src/Console/CustomFormAnswerPurgeCommand.php create mode 100644 lib/RoadizCoreBundle/src/Console/DecodePrivateKeyCommand.php create mode 100644 lib/RoadizCoreBundle/src/Console/EncodePrivateKeyCommand.php create mode 100644 lib/RoadizCoreBundle/src/Console/FilesCommandTrait.php create mode 100644 lib/RoadizCoreBundle/src/Console/FilesExportCommand.php create mode 100644 lib/RoadizCoreBundle/src/Console/FilesImportCommand.php create mode 100644 lib/RoadizCoreBundle/src/Console/GenerateApiResourceCommand.php create mode 100644 lib/RoadizCoreBundle/src/Console/GenerateNodeSourceEntitiesCommand.php create mode 100644 lib/RoadizCoreBundle/src/Console/GeneratePrivateKeyCommand.php create mode 100644 lib/RoadizCoreBundle/src/Console/InstallCommand.php create mode 100644 lib/RoadizCoreBundle/src/Console/LogsCleanupCommand.php create mode 100644 lib/RoadizCoreBundle/src/Console/MailerTestCommand.php create mode 100644 lib/RoadizCoreBundle/src/Console/NodeApplyUniversalFieldsCommand.php create mode 100644 lib/RoadizCoreBundle/src/Console/NodeClearTagCommand.php create mode 100644 lib/RoadizCoreBundle/src/Console/NodeTypesAddFieldCommand.php create mode 100644 lib/RoadizCoreBundle/src/Console/NodeTypesCommand.php create mode 100644 lib/RoadizCoreBundle/src/Console/NodeTypesCreationCommand.php create mode 100644 lib/RoadizCoreBundle/src/Console/NodeTypesDeleteCommand.php create mode 100644 lib/RoadizCoreBundle/src/Console/NodesCleanNamesCommand.php create mode 100644 lib/RoadizCoreBundle/src/Console/NodesCommand.php create mode 100644 lib/RoadizCoreBundle/src/Console/NodesCreationCommand.php create mode 100644 lib/RoadizCoreBundle/src/Console/NodesDetailsCommand.php create mode 100644 lib/RoadizCoreBundle/src/Console/NodesEmptyTrashCommand.php create mode 100644 lib/RoadizCoreBundle/src/Console/NodesOrphansCommand.php create mode 100644 lib/RoadizCoreBundle/src/Console/PrivateKeyCommand.php create mode 100644 lib/RoadizCoreBundle/src/Console/PurgeLoginAttemptCommand.php create mode 100644 lib/RoadizCoreBundle/src/Console/SolrCommand.php create mode 100644 lib/RoadizCoreBundle/src/Console/SolrOptimizeCommand.php create mode 100644 lib/RoadizCoreBundle/src/Console/SolrReindexCommand.php create mode 100644 lib/RoadizCoreBundle/src/Console/SolrResetCommand.php create mode 100644 lib/RoadizCoreBundle/src/Console/ThemeAwareCommandInterface.php create mode 100644 lib/RoadizCoreBundle/src/Console/TranslationsCommand.php create mode 100644 lib/RoadizCoreBundle/src/Console/TranslationsCreationCommand.php create mode 100644 lib/RoadizCoreBundle/src/Console/TranslationsDeleteCommand.php create mode 100644 lib/RoadizCoreBundle/src/Console/TranslationsDisableCommand.php create mode 100644 lib/RoadizCoreBundle/src/Console/TranslationsEnableCommand.php create mode 100644 lib/RoadizCoreBundle/src/Console/UsersCommand.php create mode 100644 lib/RoadizCoreBundle/src/Console/UsersCreationCommand.php create mode 100644 lib/RoadizCoreBundle/src/Console/UsersDeleteCommand.php create mode 100644 lib/RoadizCoreBundle/src/Console/UsersDisableCommand.php create mode 100644 lib/RoadizCoreBundle/src/Console/UsersEnableCommand.php create mode 100644 lib/RoadizCoreBundle/src/Console/UsersPasswordCommand.php create mode 100644 lib/RoadizCoreBundle/src/Console/UsersRolesCommand.php create mode 100644 lib/RoadizCoreBundle/src/Console/VersionsPurgeCommand.php create mode 100644 lib/RoadizCoreBundle/src/Controller/CustomFormController.php create mode 100644 lib/RoadizCoreBundle/src/Controller/DefaultNodeSourceController.php create mode 100644 lib/RoadizCoreBundle/src/Controller/HealthCheckController.php create mode 100644 lib/RoadizCoreBundle/src/Controller/RedirectionController.php create mode 100644 lib/RoadizCoreBundle/src/Crypto/UniqueKeyEncoderFactory.php create mode 100644 lib/RoadizCoreBundle/src/CustomForm/CustomFormAnswerSerializer.php create mode 100644 lib/RoadizCoreBundle/src/CustomForm/CustomFormHelper.php create mode 100644 lib/RoadizCoreBundle/src/CustomForm/CustomFormHelperFactory.php create mode 100644 lib/RoadizCoreBundle/src/DependencyInjection/Compiler/CommonMarkCompilerPass.php create mode 100644 lib/RoadizCoreBundle/src/DependencyInjection/Compiler/DoctrineMigrationCompilerPass.php create mode 100644 lib/RoadizCoreBundle/src/DependencyInjection/Compiler/DocumentRendererCompilerPass.php create mode 100644 lib/RoadizCoreBundle/src/DependencyInjection/Compiler/FlysystemStorageCompilerPass.php create mode 100644 lib/RoadizCoreBundle/src/DependencyInjection/Compiler/ImporterCompilerPass.php create mode 100644 lib/RoadizCoreBundle/src/DependencyInjection/Compiler/MediaFinderCompilerPass.php create mode 100644 lib/RoadizCoreBundle/src/DependencyInjection/Compiler/NodeWorkflowCompilerPass.php create mode 100644 lib/RoadizCoreBundle/src/DependencyInjection/Compiler/NodesSourcesEntitiesPathCompilerPass.php create mode 100644 lib/RoadizCoreBundle/src/DependencyInjection/Compiler/PathResolverCompilerPass.php create mode 100644 lib/RoadizCoreBundle/src/DependencyInjection/Compiler/RateLimitersCompilerPass.php create mode 100644 lib/RoadizCoreBundle/src/DependencyInjection/Compiler/TwigLoaderCompilerPass.php create mode 100644 lib/RoadizCoreBundle/src/DependencyInjection/Configuration.php create mode 100644 lib/RoadizCoreBundle/src/DependencyInjection/RoadizCoreExtension.php create mode 100644 lib/RoadizCoreBundle/src/Doctrine/Event/FilterNodesSourcesQueryBuilderCriteriaEvent.php create mode 100644 lib/RoadizCoreBundle/src/Doctrine/Event/FilterQueryBuilderCriteriaEvent.php create mode 100644 lib/RoadizCoreBundle/src/Doctrine/Event/FilterQueryBuilderEvent.php create mode 100644 lib/RoadizCoreBundle/src/Doctrine/Event/FilterQueryCriteriaEvent.php create mode 100644 lib/RoadizCoreBundle/src/Doctrine/Event/QueryBuilder/QueryBuilderApplyEvent.php create mode 100644 lib/RoadizCoreBundle/src/Doctrine/Event/QueryBuilder/QueryBuilderBuildEvent.php create mode 100644 lib/RoadizCoreBundle/src/Doctrine/Event/QueryBuilder/QueryBuilderNodesSourcesApplyEvent.php create mode 100644 lib/RoadizCoreBundle/src/Doctrine/Event/QueryBuilder/QueryBuilderNodesSourcesBuildEvent.php create mode 100644 lib/RoadizCoreBundle/src/Doctrine/Event/QueryBuilder/QueryBuilderSelectEvent.php create mode 100644 lib/RoadizCoreBundle/src/Doctrine/Event/QueryEvent.php create mode 100644 lib/RoadizCoreBundle/src/Doctrine/Event/QueryNodesSourcesEvent.php create mode 100644 lib/RoadizCoreBundle/src/Doctrine/EventSubscriber/AttributeValueLifeCycleSubscriber.php create mode 100644 lib/RoadizCoreBundle/src/Doctrine/EventSubscriber/CustomFormFieldLifeCycleSubscriber.php create mode 100644 lib/RoadizCoreBundle/src/Doctrine/EventSubscriber/NodesSourcesInheritanceSubscriber.php create mode 100644 lib/RoadizCoreBundle/src/Doctrine/EventSubscriber/SettingLifeCycleSubscriber.php create mode 100644 lib/RoadizCoreBundle/src/Doctrine/EventSubscriber/TablePrefixSubscriber.php create mode 100644 lib/RoadizCoreBundle/src/Doctrine/EventSubscriber/UserLifeCycleSubscriber.php create mode 100644 lib/RoadizCoreBundle/src/Doctrine/Loggable/UserLoggableListener.php create mode 100644 lib/RoadizCoreBundle/src/Doctrine/ORM/Filter/ANodesFilter.php create mode 100644 lib/RoadizCoreBundle/src/Doctrine/ORM/Filter/BNodesFilter.php create mode 100644 lib/RoadizCoreBundle/src/Doctrine/ORM/Filter/NodeTranslationFilter.php create mode 100644 lib/RoadizCoreBundle/src/Doctrine/ORM/Filter/NodeTypeFilter.php create mode 100644 lib/RoadizCoreBundle/src/Doctrine/ORM/Filter/NodesSourcesNodeFilter.php create mode 100644 lib/RoadizCoreBundle/src/Doctrine/ORM/Filter/NodesSourcesNodeTypeFilter.php create mode 100644 lib/RoadizCoreBundle/src/Doctrine/ORM/Filter/NodesSourcesReachableFilter.php create mode 100644 lib/RoadizCoreBundle/src/Doctrine/ORM/SimpleQueryBuilder.php create mode 100644 lib/RoadizCoreBundle/src/Doctrine/SchemaUpdater.php create mode 100644 lib/RoadizCoreBundle/src/Document/DocumentFactory.php create mode 100644 lib/RoadizCoreBundle/src/Document/DocumentFinder.php create mode 100644 lib/RoadizCoreBundle/src/Document/EventSubscriber/DocumentMessageDispatchSubscriber.php create mode 100644 lib/RoadizCoreBundle/src/Document/MediaFinder/DailymotionEmbedFinder.php create mode 100644 lib/RoadizCoreBundle/src/Document/MediaFinder/DeezerEmbedFinder.php create mode 100644 lib/RoadizCoreBundle/src/Document/MediaFinder/EmbedFinderTrait.php create mode 100644 lib/RoadizCoreBundle/src/Document/MediaFinder/MixcloudEmbedFinder.php create mode 100644 lib/RoadizCoreBundle/src/Document/MediaFinder/PodcastFinder.php create mode 100644 lib/RoadizCoreBundle/src/Document/MediaFinder/SoundcloudEmbedFinder.php create mode 100644 lib/RoadizCoreBundle/src/Document/MediaFinder/SpotifyEmbedFinder.php create mode 100644 lib/RoadizCoreBundle/src/Document/MediaFinder/TedEmbedFinder.php create mode 100644 lib/RoadizCoreBundle/src/Document/MediaFinder/TwitchEmbedFinder.php create mode 100644 lib/RoadizCoreBundle/src/Document/MediaFinder/UnsplashPictureFinder.php create mode 100644 lib/RoadizCoreBundle/src/Document/MediaFinder/VimeoEmbedFinder.php create mode 100644 lib/RoadizCoreBundle/src/Document/MediaFinder/YoutubeEmbedFinder.php create mode 100644 lib/RoadizCoreBundle/src/Document/Message/AbstractDocumentMessage.php create mode 100644 lib/RoadizCoreBundle/src/Document/Message/DocumentAudioVideoMessage.php create mode 100644 lib/RoadizCoreBundle/src/Document/Message/DocumentAverageColorMessage.php create mode 100644 lib/RoadizCoreBundle/src/Document/Message/DocumentExifMessage.php create mode 100644 lib/RoadizCoreBundle/src/Document/Message/DocumentFilesizeMessage.php create mode 100644 lib/RoadizCoreBundle/src/Document/Message/DocumentRawMessage.php create mode 100644 lib/RoadizCoreBundle/src/Document/Message/DocumentSizeMessage.php create mode 100644 lib/RoadizCoreBundle/src/Document/Message/DocumentSvgMessage.php create mode 100644 lib/RoadizCoreBundle/src/Document/MessageHandler/AbstractDocumentMessageHandler.php create mode 100644 lib/RoadizCoreBundle/src/Document/MessageHandler/AbstractLockingDocumentMessageHandler.php create mode 100644 lib/RoadizCoreBundle/src/Document/MessageHandler/DocumentAudioVideoMessageHandler.php create mode 100644 lib/RoadizCoreBundle/src/Document/MessageHandler/DocumentAverageColorMessageHandler.php create mode 100644 lib/RoadizCoreBundle/src/Document/MessageHandler/DocumentExifMessageHandler.php create mode 100644 lib/RoadizCoreBundle/src/Document/MessageHandler/DocumentFilesizeMessageHandler.php create mode 100644 lib/RoadizCoreBundle/src/Document/MessageHandler/DocumentRawMessageHandler.php create mode 100644 lib/RoadizCoreBundle/src/Document/MessageHandler/DocumentSizeMessageHandler.php create mode 100644 lib/RoadizCoreBundle/src/Document/MessageHandler/DocumentSvgMessageHandler.php create mode 100644 lib/RoadizCoreBundle/src/Document/PrivateDocumentFactory.php create mode 100644 lib/RoadizCoreBundle/src/Entity/Attribute.php create mode 100644 lib/RoadizCoreBundle/src/Entity/AttributeDocuments.php create mode 100644 lib/RoadizCoreBundle/src/Entity/AttributeGroup.php create mode 100644 lib/RoadizCoreBundle/src/Entity/AttributeGroupTranslation.php create mode 100644 lib/RoadizCoreBundle/src/Entity/AttributeTranslation.php create mode 100644 lib/RoadizCoreBundle/src/Entity/AttributeValue.php create mode 100644 lib/RoadizCoreBundle/src/Entity/AttributeValueTranslation.php create mode 100644 lib/RoadizCoreBundle/src/Entity/CustomForm.php create mode 100644 lib/RoadizCoreBundle/src/Entity/CustomFormAnswer.php create mode 100644 lib/RoadizCoreBundle/src/Entity/CustomFormField.php create mode 100644 lib/RoadizCoreBundle/src/Entity/CustomFormFieldAttribute.php create mode 100644 lib/RoadizCoreBundle/src/Entity/Document.php create mode 100644 lib/RoadizCoreBundle/src/Entity/DocumentTranslation.php create mode 100644 lib/RoadizCoreBundle/src/Entity/Folder.php create mode 100644 lib/RoadizCoreBundle/src/Entity/FolderTranslation.php create mode 100644 lib/RoadizCoreBundle/src/Entity/Group.php create mode 100644 lib/RoadizCoreBundle/src/Entity/Log.php create mode 100644 lib/RoadizCoreBundle/src/Entity/LoginAttempt.php create mode 100644 lib/RoadizCoreBundle/src/Entity/Node.php create mode 100644 lib/RoadizCoreBundle/src/Entity/NodeType.php create mode 100644 lib/RoadizCoreBundle/src/Entity/NodeTypeField.php create mode 100644 lib/RoadizCoreBundle/src/Entity/NodesCustomForms.php create mode 100644 lib/RoadizCoreBundle/src/Entity/NodesSources.php create mode 100644 lib/RoadizCoreBundle/src/Entity/NodesSourcesDocuments.php create mode 100644 lib/RoadizCoreBundle/src/Entity/NodesTags.php create mode 100644 lib/RoadizCoreBundle/src/Entity/NodesToNodes.php create mode 100644 lib/RoadizCoreBundle/src/Entity/Realm.php create mode 100644 lib/RoadizCoreBundle/src/Entity/RealmNode.php create mode 100644 lib/RoadizCoreBundle/src/Entity/Redirection.php create mode 100644 lib/RoadizCoreBundle/src/Entity/Role.php create mode 100644 lib/RoadizCoreBundle/src/Entity/Setting.php create mode 100644 lib/RoadizCoreBundle/src/Entity/SettingGroup.php create mode 100644 lib/RoadizCoreBundle/src/Entity/Tag.php create mode 100644 lib/RoadizCoreBundle/src/Entity/TagTranslation.php create mode 100644 lib/RoadizCoreBundle/src/Entity/TagTranslationDocuments.php create mode 100644 lib/RoadizCoreBundle/src/Entity/Theme.php create mode 100644 lib/RoadizCoreBundle/src/Entity/Translation.php create mode 100644 lib/RoadizCoreBundle/src/Entity/UrlAlias.php create mode 100644 lib/RoadizCoreBundle/src/Entity/User.php create mode 100644 lib/RoadizCoreBundle/src/Entity/UserLogEntry.php create mode 100644 lib/RoadizCoreBundle/src/Entity/Webhook.php create mode 100644 lib/RoadizCoreBundle/src/EntityApi/AbstractApi.php create mode 100644 lib/RoadizCoreBundle/src/EntityApi/NodeApi.php create mode 100644 lib/RoadizCoreBundle/src/EntityApi/NodeSourceApi.php create mode 100644 lib/RoadizCoreBundle/src/EntityApi/NodeTypeApi.php create mode 100644 lib/RoadizCoreBundle/src/EntityApi/TagApi.php create mode 100644 lib/RoadizCoreBundle/src/EntityHandler/CustomFormFieldHandler.php create mode 100644 lib/RoadizCoreBundle/src/EntityHandler/CustomFormHandler.php create mode 100644 lib/RoadizCoreBundle/src/EntityHandler/DocumentHandler.php create mode 100644 lib/RoadizCoreBundle/src/EntityHandler/FolderHandler.php create mode 100644 lib/RoadizCoreBundle/src/EntityHandler/GroupHandler.php create mode 100644 lib/RoadizCoreBundle/src/EntityHandler/HandlerFactory.php create mode 100644 lib/RoadizCoreBundle/src/EntityHandler/NodeHandler.php create mode 100644 lib/RoadizCoreBundle/src/EntityHandler/NodeTypeFieldHandler.php create mode 100644 lib/RoadizCoreBundle/src/EntityHandler/NodeTypeHandler.php create mode 100644 lib/RoadizCoreBundle/src/EntityHandler/NodesSourcesHandler.php create mode 100644 lib/RoadizCoreBundle/src/EntityHandler/TagHandler.php create mode 100644 lib/RoadizCoreBundle/src/EntityHandler/TranslationHandler.php create mode 100644 lib/RoadizCoreBundle/src/Event/Cache/CachePurgeRequestEvent.php create mode 100644 lib/RoadizCoreBundle/src/Event/Document/DocumentTranslationIndexingEvent.php create mode 100644 lib/RoadizCoreBundle/src/Event/Document/DocumentTranslationUpdatedEvent.php create mode 100644 lib/RoadizCoreBundle/src/Event/FilterCacheEvent.php create mode 100644 lib/RoadizCoreBundle/src/Event/FilterFolderEvent.php create mode 100644 lib/RoadizCoreBundle/src/Event/FilterNodeEvent.php create mode 100644 lib/RoadizCoreBundle/src/Event/FilterNodePathEvent.php create mode 100644 lib/RoadizCoreBundle/src/Event/FilterNodesSourcesEvent.php create mode 100644 lib/RoadizCoreBundle/src/Event/FilterSettingEvent.php create mode 100644 lib/RoadizCoreBundle/src/Event/FilterTagEvent.php create mode 100644 lib/RoadizCoreBundle/src/Event/FilterTranslationEvent.php create mode 100644 lib/RoadizCoreBundle/src/Event/FilterUrlAliasEvent.php create mode 100644 lib/RoadizCoreBundle/src/Event/FilterUserEvent.php create mode 100644 lib/RoadizCoreBundle/src/Event/Folder/FolderCreatedEvent.php create mode 100644 lib/RoadizCoreBundle/src/Event/Folder/FolderDeletedEvent.php create mode 100644 lib/RoadizCoreBundle/src/Event/Folder/FolderUpdatedEvent.php create mode 100644 lib/RoadizCoreBundle/src/Event/Node/NodeCreatedEvent.php create mode 100644 lib/RoadizCoreBundle/src/Event/Node/NodeDeletedEvent.php create mode 100644 lib/RoadizCoreBundle/src/Event/Node/NodeDuplicatedEvent.php create mode 100644 lib/RoadizCoreBundle/src/Event/Node/NodePathChangedEvent.php create mode 100644 lib/RoadizCoreBundle/src/Event/Node/NodeTaggedEvent.php create mode 100644 lib/RoadizCoreBundle/src/Event/Node/NodeUndeletedEvent.php create mode 100644 lib/RoadizCoreBundle/src/Event/Node/NodeUpdatedEvent.php create mode 100644 lib/RoadizCoreBundle/src/Event/Node/NodeVisibilityChangedEvent.php create mode 100644 lib/RoadizCoreBundle/src/Event/NodesSources/NodesSourcesCreatedEvent.php create mode 100644 lib/RoadizCoreBundle/src/Event/NodesSources/NodesSourcesDeletedEvent.php create mode 100644 lib/RoadizCoreBundle/src/Event/NodesSources/NodesSourcesIndexingEvent.php create mode 100644 lib/RoadizCoreBundle/src/Event/NodesSources/NodesSourcesPathGeneratingEvent.php create mode 100644 lib/RoadizCoreBundle/src/Event/NodesSources/NodesSourcesPreUpdatedEvent.php create mode 100644 lib/RoadizCoreBundle/src/Event/NodesSources/NodesSourcesUpdatedEvent.php create mode 100644 lib/RoadizCoreBundle/src/Event/Realm/AbstractRealmNodeEvent.php create mode 100644 lib/RoadizCoreBundle/src/Event/Realm/NodeJoinedRealmEvent.php create mode 100644 lib/RoadizCoreBundle/src/Event/Realm/NodeLeftRealmEvent.php create mode 100644 lib/RoadizCoreBundle/src/Event/Role/PreCreatedRoleEvent.php create mode 100644 lib/RoadizCoreBundle/src/Event/Role/PreDeletedRoleEvent.php create mode 100644 lib/RoadizCoreBundle/src/Event/Role/PreUpdatedRoleEvent.php create mode 100644 lib/RoadizCoreBundle/src/Event/Role/RoleEvent.php create mode 100644 lib/RoadizCoreBundle/src/Event/Setting/SettingCreatedEvent.php create mode 100644 lib/RoadizCoreBundle/src/Event/Setting/SettingDeletedEvent.php create mode 100644 lib/RoadizCoreBundle/src/Event/Setting/SettingUpdatedEvent.php create mode 100644 lib/RoadizCoreBundle/src/Event/Tag/TagCreatedEvent.php create mode 100644 lib/RoadizCoreBundle/src/Event/Tag/TagDeletedEvent.php create mode 100644 lib/RoadizCoreBundle/src/Event/Tag/TagUpdatedEvent.php create mode 100644 lib/RoadizCoreBundle/src/Event/Translation/TranslationCreatedEvent.php create mode 100644 lib/RoadizCoreBundle/src/Event/Translation/TranslationDeletedEvent.php create mode 100644 lib/RoadizCoreBundle/src/Event/Translation/TranslationUpdatedEvent.php create mode 100644 lib/RoadizCoreBundle/src/Event/UrlAlias/UrlAliasCreatedEvent.php create mode 100644 lib/RoadizCoreBundle/src/Event/UrlAlias/UrlAliasDeletedEvent.php create mode 100644 lib/RoadizCoreBundle/src/Event/UrlAlias/UrlAliasUpdatedEvent.php create mode 100644 lib/RoadizCoreBundle/src/Event/User/UserCreatedEvent.php create mode 100644 lib/RoadizCoreBundle/src/Event/User/UserDeletedEvent.php create mode 100644 lib/RoadizCoreBundle/src/Event/User/UserDisabledEvent.php create mode 100644 lib/RoadizCoreBundle/src/Event/User/UserEnabledEvent.php create mode 100644 lib/RoadizCoreBundle/src/Event/User/UserJoinedGroupEvent.php create mode 100644 lib/RoadizCoreBundle/src/Event/User/UserLeavedGroupEvent.php create mode 100644 lib/RoadizCoreBundle/src/Event/User/UserPasswordChangedEvent.php create mode 100644 lib/RoadizCoreBundle/src/Event/User/UserUpdatedEvent.php create mode 100644 lib/RoadizCoreBundle/src/EventSubscriber/AssetsCacheEventSubscriber.php create mode 100644 lib/RoadizCoreBundle/src/EventSubscriber/AttributeValueIndexingSubscriber.php create mode 100644 lib/RoadizCoreBundle/src/EventSubscriber/AutomaticWebhookSubscriber.php create mode 100644 lib/RoadizCoreBundle/src/EventSubscriber/CloudflareCacheEventSubscriber.php create mode 100644 lib/RoadizCoreBundle/src/EventSubscriber/DocumentTimestampSubscriber.php create mode 100644 lib/RoadizCoreBundle/src/EventSubscriber/LocaleSubscriber.php create mode 100644 lib/RoadizCoreBundle/src/EventSubscriber/LoggableUsernameSubscriber.php create mode 100644 lib/RoadizCoreBundle/src/EventSubscriber/NodeDuplicationSubscriber.php create mode 100644 lib/RoadizCoreBundle/src/EventSubscriber/NodeNameSubscriber.php create mode 100644 lib/RoadizCoreBundle/src/EventSubscriber/NodeRedirectionSubscriber.php create mode 100644 lib/RoadizCoreBundle/src/EventSubscriber/NodeSourcePathSubscriber.php create mode 100644 lib/RoadizCoreBundle/src/EventSubscriber/NodesSourcesLinkHeaderEventSubscriber.php create mode 100644 lib/RoadizCoreBundle/src/EventSubscriber/NodesSourcesUniversalSubscriber.php create mode 100644 lib/RoadizCoreBundle/src/EventSubscriber/NodesSourcesUrlsCacheEventSubscriber.php create mode 100644 lib/RoadizCoreBundle/src/EventSubscriber/OPCacheEventSubscriber.php create mode 100644 lib/RoadizCoreBundle/src/EventSubscriber/RealmNodeInheritanceSubscriber.php create mode 100644 lib/RoadizCoreBundle/src/EventSubscriber/ReverseProxyCacheEventSubscriber.php create mode 100644 lib/RoadizCoreBundle/src/EventSubscriber/RoleSubscriber.php create mode 100644 lib/RoadizCoreBundle/src/EventSubscriber/SignatureSubscriber.php create mode 100644 lib/RoadizCoreBundle/src/EventSubscriber/TagTimestampSubscriber.php create mode 100644 lib/RoadizCoreBundle/src/EventSubscriber/TranslationSubscriber.php create mode 100644 lib/RoadizCoreBundle/src/EventSubscriber/UserLocaleSubscriber.php create mode 100644 lib/RoadizCoreBundle/src/Exception/BadFormRequestException.php create mode 100644 lib/RoadizCoreBundle/src/Exception/EmbedPlatformNotSupportedException.php create mode 100644 lib/RoadizCoreBundle/src/Exception/EmptySaltException.php create mode 100644 lib/RoadizCoreBundle/src/Exception/EntityAlreadyExistsException.php create mode 100644 lib/RoadizCoreBundle/src/Exception/EntityRequiredException.php create mode 100644 lib/RoadizCoreBundle/src/Exception/ExceptionViewer.php create mode 100644 lib/RoadizCoreBundle/src/Exception/FacebookUsernameNotFoundException.php create mode 100644 lib/RoadizCoreBundle/src/Exception/ForceResponseException.php create mode 100644 lib/RoadizCoreBundle/src/Exception/MaintenanceModeException.php create mode 100644 lib/RoadizCoreBundle/src/Exception/NoConfigurationFoundException.php create mode 100644 lib/RoadizCoreBundle/src/Exception/NoTranslationAvailableException.php create mode 100644 lib/RoadizCoreBundle/src/Exception/NoYamlConfigurationFoundException.php create mode 100644 lib/RoadizCoreBundle/src/Exception/ReservedSQLWordException.php create mode 100644 lib/RoadizCoreBundle/src/Exception/SolrServerNotAvailableException.php create mode 100644 lib/RoadizCoreBundle/src/Exception/SolrServerNotConfiguredException.php create mode 100644 lib/RoadizCoreBundle/src/Exception/ThemeClassNotValidException.php create mode 100644 lib/RoadizCoreBundle/src/Exception/TooManyLoginAttemptsException.php create mode 100644 lib/RoadizCoreBundle/src/Explorer/AbstractDoctrineExplorerProvider.php create mode 100644 lib/RoadizCoreBundle/src/Explorer/AbstractExplorerItem.php create mode 100644 lib/RoadizCoreBundle/src/Explorer/AbstractExplorerProvider.php create mode 100644 lib/RoadizCoreBundle/src/Explorer/ExplorerItemInterface.php create mode 100644 lib/RoadizCoreBundle/src/Explorer/ExplorerProviderInterface.php create mode 100644 lib/RoadizCoreBundle/src/Filesystem/RoadizFileDirectories.php create mode 100644 lib/RoadizCoreBundle/src/Form/AttributeChoiceType.php create mode 100644 lib/RoadizCoreBundle/src/Form/AttributeDocumentType.php create mode 100644 lib/RoadizCoreBundle/src/Form/AttributeGroupTranslationType.php create mode 100644 lib/RoadizCoreBundle/src/Form/AttributeGroupType.php create mode 100644 lib/RoadizCoreBundle/src/Form/AttributeGroupsType.php create mode 100644 lib/RoadizCoreBundle/src/Form/AttributeImportType.php create mode 100644 lib/RoadizCoreBundle/src/Form/AttributeTranslationType.php create mode 100644 lib/RoadizCoreBundle/src/Form/AttributeType.php create mode 100644 lib/RoadizCoreBundle/src/Form/AttributeValueTranslationType.php create mode 100644 lib/RoadizCoreBundle/src/Form/AttributeValueType.php create mode 100644 lib/RoadizCoreBundle/src/Form/ColorType.php create mode 100644 lib/RoadizCoreBundle/src/Form/CompareDateType.php create mode 100644 lib/RoadizCoreBundle/src/Form/CompareDatetimeType.php create mode 100644 lib/RoadizCoreBundle/src/Form/Constraint/HexadecimalColor.php create mode 100644 lib/RoadizCoreBundle/src/Form/Constraint/HexadecimalColorValidator.php create mode 100644 lib/RoadizCoreBundle/src/Form/Constraint/NodeTypeField.php create mode 100644 lib/RoadizCoreBundle/src/Form/Constraint/NodeTypeFieldValidator.php create mode 100644 lib/RoadizCoreBundle/src/Form/Constraint/NonSqlReservedWord.php create mode 100644 lib/RoadizCoreBundle/src/Form/Constraint/NonSqlReservedWordValidator.php create mode 100644 lib/RoadizCoreBundle/src/Form/Constraint/Recaptcha.php create mode 100644 lib/RoadizCoreBundle/src/Form/Constraint/RecaptchaServiceInterface.php create mode 100644 lib/RoadizCoreBundle/src/Form/Constraint/RecaptchaValidator.php create mode 100644 lib/RoadizCoreBundle/src/Form/Constraint/SimpleLatinString.php create mode 100644 lib/RoadizCoreBundle/src/Form/Constraint/SimpleLatinStringValidator.php create mode 100644 lib/RoadizCoreBundle/src/Form/Constraint/UniqueEntity.php create mode 100644 lib/RoadizCoreBundle/src/Form/Constraint/UniqueEntityValidator.php create mode 100644 lib/RoadizCoreBundle/src/Form/Constraint/UniqueFilename.php create mode 100644 lib/RoadizCoreBundle/src/Form/Constraint/UniqueFilenameValidator.php create mode 100644 lib/RoadizCoreBundle/src/Form/Constraint/UniqueNodeName.php create mode 100644 lib/RoadizCoreBundle/src/Form/Constraint/UniqueNodeNameValidator.php create mode 100644 lib/RoadizCoreBundle/src/Form/Constraint/UniqueTagName.php create mode 100644 lib/RoadizCoreBundle/src/Form/Constraint/UniqueTagNameValidator.php create mode 100644 lib/RoadizCoreBundle/src/Form/Constraint/ValidAccountConfirmationToken.php create mode 100644 lib/RoadizCoreBundle/src/Form/Constraint/ValidAccountConfirmationTokenValidator.php create mode 100644 lib/RoadizCoreBundle/src/Form/Constraint/ValidAccountEmail.php create mode 100644 lib/RoadizCoreBundle/src/Form/Constraint/ValidAccountEmailValidator.php create mode 100644 lib/RoadizCoreBundle/src/Form/Constraint/ValidFacebookName.php create mode 100644 lib/RoadizCoreBundle/src/Form/Constraint/ValidFacebookNameValidator.php create mode 100644 lib/RoadizCoreBundle/src/Form/Constraint/ValidJson.php create mode 100644 lib/RoadizCoreBundle/src/Form/Constraint/ValidJsonValidator.php create mode 100644 lib/RoadizCoreBundle/src/Form/Constraint/ValidYaml.php create mode 100644 lib/RoadizCoreBundle/src/Form/Constraint/ValidYamlValidator.php create mode 100644 lib/RoadizCoreBundle/src/Form/CreatePasswordType.php create mode 100644 lib/RoadizCoreBundle/src/Form/CssType.php create mode 100644 lib/RoadizCoreBundle/src/Form/CustomFormsType.php create mode 100644 lib/RoadizCoreBundle/src/Form/DataTransformer/AttributeDocumentsTransformer.php create mode 100644 lib/RoadizCoreBundle/src/Form/DataTransformer/AttributeGroupTransformer.php create mode 100644 lib/RoadizCoreBundle/src/Form/DataTransformer/DocumentCollectionTransformer.php create mode 100644 lib/RoadizCoreBundle/src/Form/DataTransformer/EntityCollectionTransformer.php create mode 100644 lib/RoadizCoreBundle/src/Form/DataTransformer/ExplorerProviderItemTransformer.php create mode 100644 lib/RoadizCoreBundle/src/Form/DataTransformer/FolderCollectionTransformer.php create mode 100644 lib/RoadizCoreBundle/src/Form/DataTransformer/JoinDataTransformer.php create mode 100644 lib/RoadizCoreBundle/src/Form/DataTransformer/NodeTypeTransformer.php create mode 100644 lib/RoadizCoreBundle/src/Form/DataTransformer/PersistableTransformer.php create mode 100644 lib/RoadizCoreBundle/src/Form/DataTransformer/ProviderDataTransformer.php create mode 100644 lib/RoadizCoreBundle/src/Form/DataTransformer/ReversePersistableTransformer.php create mode 100644 lib/RoadizCoreBundle/src/Form/DataTransformer/TagTranslationDocumentsTransformer.php create mode 100644 lib/RoadizCoreBundle/src/Form/DataTransformer/TranslationTransformer.php create mode 100644 lib/RoadizCoreBundle/src/Form/DocumentCollectionType.php create mode 100644 lib/RoadizCoreBundle/src/Form/EnumerationType.php create mode 100644 lib/RoadizCoreBundle/src/Form/Error/FormErrorSerializer.php create mode 100644 lib/RoadizCoreBundle/src/Form/Error/FormErrorSerializerInterface.php create mode 100644 lib/RoadizCoreBundle/src/Form/ExplorerProviderItemType.php create mode 100644 lib/RoadizCoreBundle/src/Form/ExtendedBooleanType.php create mode 100644 lib/RoadizCoreBundle/src/Form/Extension/HelpAndGroupExtension.php create mode 100644 lib/RoadizCoreBundle/src/Form/GroupsType.php create mode 100644 lib/RoadizCoreBundle/src/Form/HoneypotType.php create mode 100644 lib/RoadizCoreBundle/src/Form/JsonType.php create mode 100644 lib/RoadizCoreBundle/src/Form/LoginRequestForm.php create mode 100644 lib/RoadizCoreBundle/src/Form/LoginResetForm.php create mode 100644 lib/RoadizCoreBundle/src/Form/MarkdownType.php create mode 100644 lib/RoadizCoreBundle/src/Form/MultipleEnumerationType.php create mode 100644 lib/RoadizCoreBundle/src/Form/NodeStatesType.php create mode 100644 lib/RoadizCoreBundle/src/Form/NodeTypesType.php create mode 100644 lib/RoadizCoreBundle/src/Form/NodesType.php create mode 100644 lib/RoadizCoreBundle/src/Form/RealmChoiceType.php create mode 100644 lib/RoadizCoreBundle/src/Form/RealmNodeType.php create mode 100644 lib/RoadizCoreBundle/src/Form/RealmType.php create mode 100644 lib/RoadizCoreBundle/src/Form/RecaptchaType.php create mode 100644 lib/RoadizCoreBundle/src/Form/RoleEntityType.php create mode 100644 lib/RoadizCoreBundle/src/Form/RolesType.php create mode 100644 lib/RoadizCoreBundle/src/Form/SeparatorType.php create mode 100644 lib/RoadizCoreBundle/src/Form/SettingDocumentType.php create mode 100644 lib/RoadizCoreBundle/src/Form/SettingGroupType.php create mode 100644 lib/RoadizCoreBundle/src/Form/SettingType.php create mode 100644 lib/RoadizCoreBundle/src/Form/SettingTypeResolver.php create mode 100644 lib/RoadizCoreBundle/src/Form/TagTranslationDocumentType.php create mode 100644 lib/RoadizCoreBundle/src/Form/TagsType.php create mode 100644 lib/RoadizCoreBundle/src/Form/ThemesType.php create mode 100644 lib/RoadizCoreBundle/src/Form/TranslationsType.php create mode 100644 lib/RoadizCoreBundle/src/Form/UrlAliasType.php create mode 100644 lib/RoadizCoreBundle/src/Form/UserCollectionType.php create mode 100644 lib/RoadizCoreBundle/src/Form/UsersType.php create mode 100644 lib/RoadizCoreBundle/src/Form/WebhookType.php create mode 100644 lib/RoadizCoreBundle/src/Form/WebhooksChoiceType.php create mode 100644 lib/RoadizCoreBundle/src/Form/YamlType.php create mode 100644 lib/RoadizCoreBundle/src/Importer/AttributeImporter.php create mode 100644 lib/RoadizCoreBundle/src/Importer/ChainImporter.php create mode 100644 lib/RoadizCoreBundle/src/Importer/EntityImporterInterface.php create mode 100644 lib/RoadizCoreBundle/src/Importer/GroupsImporter.php create mode 100644 lib/RoadizCoreBundle/src/Importer/NodeTypesImporter.php create mode 100644 lib/RoadizCoreBundle/src/Importer/RolesImporter.php create mode 100644 lib/RoadizCoreBundle/src/Importer/SettingsImporter.php create mode 100644 lib/RoadizCoreBundle/src/Importer/TagsImporter.php create mode 100644 lib/RoadizCoreBundle/src/ListManager/AbstractEntityListManager.php create mode 100644 lib/RoadizCoreBundle/src/ListManager/EntityListManager.php create mode 100644 lib/RoadizCoreBundle/src/ListManager/EntityListManagerInterface.php create mode 100644 lib/RoadizCoreBundle/src/ListManager/NodePaginator.php create mode 100644 lib/RoadizCoreBundle/src/ListManager/NodesSourcesPaginator.php create mode 100644 lib/RoadizCoreBundle/src/ListManager/Paginator.php create mode 100644 lib/RoadizCoreBundle/src/ListManager/QueryBuilderListManager.php create mode 100644 lib/RoadizCoreBundle/src/ListManager/TagListManager.php create mode 100644 lib/RoadizCoreBundle/src/Logger/DoctrineHandler.php create mode 100644 lib/RoadizCoreBundle/src/Mailer/ContactFormManager.php create mode 100644 lib/RoadizCoreBundle/src/Mailer/EmailManager.php create mode 100644 lib/RoadizCoreBundle/src/Message/ApplyRealmNodeInheritanceMessage.php create mode 100644 lib/RoadizCoreBundle/src/Message/AsyncMessage.php create mode 100644 lib/RoadizCoreBundle/src/Message/CleanRealmNodeInheritanceMessage.php create mode 100644 lib/RoadizCoreBundle/src/Message/DeleteNodeTypeMessage.php create mode 100644 lib/RoadizCoreBundle/src/Message/GuzzleRequestMessage.php create mode 100644 lib/RoadizCoreBundle/src/Message/Handler/ApplyRealmNodeInheritanceMessageHandler.php create mode 100644 lib/RoadizCoreBundle/src/Message/Handler/CleanRealmNodeInheritanceMessageHandler.php create mode 100644 lib/RoadizCoreBundle/src/Message/Handler/DeleteNodeTypeMessageHandler.php create mode 100644 lib/RoadizCoreBundle/src/Message/Handler/HttpRequestMessageHandler.php create mode 100644 lib/RoadizCoreBundle/src/Message/Handler/PurgeReverseProxyCacheMessageHandler.php create mode 100644 lib/RoadizCoreBundle/src/Message/Handler/SearchRealmNodeInheritanceMessageHandler.php create mode 100644 lib/RoadizCoreBundle/src/Message/Handler/UpdateDoctrineSchemaMessageHandler.php create mode 100644 lib/RoadizCoreBundle/src/Message/Handler/UpdateNodeTypeSchemaMessageHandler.php create mode 100644 lib/RoadizCoreBundle/src/Message/HttpRequestMessage.php create mode 100644 lib/RoadizCoreBundle/src/Message/PurgeReverseProxyCacheMessage.php create mode 100644 lib/RoadizCoreBundle/src/Message/SearchRealmNodeInheritanceMessage.php create mode 100644 lib/RoadizCoreBundle/src/Message/UpdateDoctrineSchemaMessage.php create mode 100644 lib/RoadizCoreBundle/src/Message/UpdateNodeTypeSchemaMessage.php create mode 100644 lib/RoadizCoreBundle/src/Model/AttributableInterface.php create mode 100644 lib/RoadizCoreBundle/src/Model/AttributableTrait.php create mode 100644 lib/RoadizCoreBundle/src/Model/AttributeGroupInterface.php create mode 100644 lib/RoadizCoreBundle/src/Model/AttributeGroupTrait.php create mode 100644 lib/RoadizCoreBundle/src/Model/AttributeGroupTranslationInterface.php create mode 100644 lib/RoadizCoreBundle/src/Model/AttributeGroupTranslationTrait.php create mode 100644 lib/RoadizCoreBundle/src/Model/AttributeInterface.php create mode 100644 lib/RoadizCoreBundle/src/Model/AttributeTrait.php create mode 100644 lib/RoadizCoreBundle/src/Model/AttributeTranslationInterface.php create mode 100644 lib/RoadizCoreBundle/src/Model/AttributeTranslationTrait.php create mode 100644 lib/RoadizCoreBundle/src/Model/AttributeValueInterface.php create mode 100644 lib/RoadizCoreBundle/src/Model/AttributeValueTrait.php create mode 100644 lib/RoadizCoreBundle/src/Model/AttributeValueTranslationInterface.php create mode 100644 lib/RoadizCoreBundle/src/Model/AttributeValueTranslationTrait.php create mode 100644 lib/RoadizCoreBundle/src/Model/RealmInterface.php create mode 100644 lib/RoadizCoreBundle/src/Node/Exception/SameNodeUrlException.php create mode 100644 lib/RoadizCoreBundle/src/Node/NodeDuplicator.php create mode 100644 lib/RoadizCoreBundle/src/Node/NodeFactory.php create mode 100644 lib/RoadizCoreBundle/src/Node/NodeMover.php create mode 100644 lib/RoadizCoreBundle/src/Node/NodeNameChecker.php create mode 100644 lib/RoadizCoreBundle/src/Node/NodeNamePolicyFactory.php create mode 100644 lib/RoadizCoreBundle/src/Node/NodeNamePolicyInterface.php create mode 100644 lib/RoadizCoreBundle/src/Node/NodeTranslator.php create mode 100644 lib/RoadizCoreBundle/src/Node/NodeTranstyper.php create mode 100644 lib/RoadizCoreBundle/src/Node/UniqueNodeGenerator.php create mode 100644 lib/RoadizCoreBundle/src/Node/UniversalDataDuplicator.php create mode 100644 lib/RoadizCoreBundle/src/NodeType/ApiResourceGenerator.php create mode 100644 lib/RoadizCoreBundle/src/NodeType/NodeTypeResolver.php create mode 100644 lib/RoadizCoreBundle/src/Preview/EventSubscriber/PreviewBarSubscriber.php create mode 100644 lib/RoadizCoreBundle/src/Preview/EventSubscriber/PreviewModeSubscriber.php create mode 100644 lib/RoadizCoreBundle/src/Preview/Exception/PreviewNotAllowedException.php create mode 100644 lib/RoadizCoreBundle/src/Preview/PreviewResolverInterface.php create mode 100644 lib/RoadizCoreBundle/src/Preview/RequestPreviewRevolver.php create mode 100644 lib/RoadizCoreBundle/src/Preview/User/PreviewUser.php create mode 100644 lib/RoadizCoreBundle/src/Preview/User/PreviewUserProvider.php create mode 100644 lib/RoadizCoreBundle/src/Preview/User/PreviewUserProviderInterface.php create mode 100644 lib/RoadizCoreBundle/src/Realm/RealmResolver.php create mode 100644 lib/RoadizCoreBundle/src/Realm/RealmResolverInterface.php create mode 100644 lib/RoadizCoreBundle/src/Repository/AttributeDocumentsRepository.php create mode 100644 lib/RoadizCoreBundle/src/Repository/AttributeGroupRepository.php create mode 100644 lib/RoadizCoreBundle/src/Repository/AttributeGroupTranslationRepository.php create mode 100644 lib/RoadizCoreBundle/src/Repository/AttributeRepository.php create mode 100644 lib/RoadizCoreBundle/src/Repository/AttributeTranslationRepository.php create mode 100644 lib/RoadizCoreBundle/src/Repository/AttributeValueRepository.php create mode 100644 lib/RoadizCoreBundle/src/Repository/AttributeValueTranslationRepository.php create mode 100644 lib/RoadizCoreBundle/src/Repository/CustomFormAnswerRepository.php create mode 100644 lib/RoadizCoreBundle/src/Repository/CustomFormFieldAttributeRepository.php create mode 100644 lib/RoadizCoreBundle/src/Repository/CustomFormFieldRepository.php create mode 100644 lib/RoadizCoreBundle/src/Repository/CustomFormRepository.php create mode 100644 lib/RoadizCoreBundle/src/Repository/DocumentRepository.php create mode 100644 lib/RoadizCoreBundle/src/Repository/DocumentTranslationRepository.php create mode 100644 lib/RoadizCoreBundle/src/Repository/EntityRepository.php create mode 100644 lib/RoadizCoreBundle/src/Repository/FolderRepository.php create mode 100644 lib/RoadizCoreBundle/src/Repository/FolderTranslationRepository.php create mode 100644 lib/RoadizCoreBundle/src/Repository/GroupRepository.php create mode 100644 lib/RoadizCoreBundle/src/Repository/LogRepository.php create mode 100644 lib/RoadizCoreBundle/src/Repository/LoginAttemptRepository.php create mode 100644 lib/RoadizCoreBundle/src/Repository/NodeRepository.php create mode 100644 lib/RoadizCoreBundle/src/Repository/NodeTypeFieldRepository.php create mode 100644 lib/RoadizCoreBundle/src/Repository/NodeTypeRepository.php create mode 100644 lib/RoadizCoreBundle/src/Repository/NodesCustomFormsRepository.php create mode 100644 lib/RoadizCoreBundle/src/Repository/NodesSourcesDocumentsRepository.php create mode 100644 lib/RoadizCoreBundle/src/Repository/NodesSourcesRepository.php create mode 100644 lib/RoadizCoreBundle/src/Repository/NodesToNodesRepository.php create mode 100644 lib/RoadizCoreBundle/src/Repository/PrefixAwareRepository.php create mode 100644 lib/RoadizCoreBundle/src/Repository/RealmNodeRepository.php create mode 100644 lib/RoadizCoreBundle/src/Repository/RealmRepository.php create mode 100644 lib/RoadizCoreBundle/src/Repository/RedirectionRepository.php create mode 100644 lib/RoadizCoreBundle/src/Repository/RoleRepository.php create mode 100644 lib/RoadizCoreBundle/src/Repository/SettingGroupRepository.php create mode 100644 lib/RoadizCoreBundle/src/Repository/SettingRepository.php create mode 100644 lib/RoadizCoreBundle/src/Repository/StatusAwareRepository.php create mode 100644 lib/RoadizCoreBundle/src/Repository/TagRepository.php create mode 100644 lib/RoadizCoreBundle/src/Repository/TagTranslationDocumentsRepository.php create mode 100644 lib/RoadizCoreBundle/src/Repository/TagTranslationRepository.php create mode 100644 lib/RoadizCoreBundle/src/Repository/TranslationRepository.php create mode 100644 lib/RoadizCoreBundle/src/Repository/UrlAliasRepository.php create mode 100644 lib/RoadizCoreBundle/src/Repository/UserLogEntryRepository.php create mode 100644 lib/RoadizCoreBundle/src/Repository/UserRepository.php create mode 100644 lib/RoadizCoreBundle/src/Repository/WebhookRepository.php create mode 100644 lib/RoadizCoreBundle/src/RoadizCoreBundle.php create mode 100644 lib/RoadizCoreBundle/src/Routing/ChainResourcePathResolver.php create mode 100644 lib/RoadizCoreBundle/src/Routing/DeferredRouteCollection.php create mode 100644 lib/RoadizCoreBundle/src/Routing/DocumentUrlGenerator.php create mode 100644 lib/RoadizCoreBundle/src/Routing/DynamicUrlMatcher.php create mode 100644 lib/RoadizCoreBundle/src/Routing/InstallRouteCollection.php create mode 100644 lib/RoadizCoreBundle/src/Routing/NodePathInfo.php create mode 100644 lib/RoadizCoreBundle/src/Routing/NodeRouteHelper.php create mode 100644 lib/RoadizCoreBundle/src/Routing/NodeRouter.php create mode 100644 lib/RoadizCoreBundle/src/Routing/NodeUrlMatcher.php create mode 100644 lib/RoadizCoreBundle/src/Routing/NodeUrlMatcherInterface.php create mode 100644 lib/RoadizCoreBundle/src/Routing/NodesSourcesPathAggregator.php create mode 100644 lib/RoadizCoreBundle/src/Routing/NodesSourcesPathResolver.php create mode 100644 lib/RoadizCoreBundle/src/Routing/NodesSourcesUrlGenerator.php create mode 100644 lib/RoadizCoreBundle/src/Routing/NullLoader.php create mode 100644 lib/RoadizCoreBundle/src/Routing/OptimizedNodesSourcesGraphPathAggregator.php create mode 100644 lib/RoadizCoreBundle/src/Routing/PathResolverInterface.php create mode 100644 lib/RoadizCoreBundle/src/Routing/RedirectableUrlMatcher.php create mode 100644 lib/RoadizCoreBundle/src/Routing/RedirectionMatcher.php create mode 100644 lib/RoadizCoreBundle/src/Routing/RedirectionPathResolver.php create mode 100644 lib/RoadizCoreBundle/src/Routing/RedirectionRouter.php create mode 100644 lib/RoadizCoreBundle/src/Routing/ResourceInfo.php create mode 100644 lib/RoadizCoreBundle/src/Routing/RouteHandler.php create mode 100644 lib/RoadizCoreBundle/src/Routing/StaticRouter.php create mode 100644 lib/RoadizCoreBundle/src/SearchEngine/AbstractSearchHandler.php create mode 100644 lib/RoadizCoreBundle/src/SearchEngine/AbstractSolarium.php create mode 100644 lib/RoadizCoreBundle/src/SearchEngine/ClientRegistry.php create mode 100644 lib/RoadizCoreBundle/src/SearchEngine/DocumentSearchHandler.php create mode 100644 lib/RoadizCoreBundle/src/SearchEngine/GlobalNodeSourceSearchHandler.php create mode 100644 lib/RoadizCoreBundle/src/SearchEngine/Indexer/AbstractIndexer.php create mode 100644 lib/RoadizCoreBundle/src/SearchEngine/Indexer/BatchIndexer.php create mode 100644 lib/RoadizCoreBundle/src/SearchEngine/Indexer/CliAwareIndexer.php create mode 100644 lib/RoadizCoreBundle/src/SearchEngine/Indexer/DocumentIndexer.php create mode 100644 lib/RoadizCoreBundle/src/SearchEngine/Indexer/FolderIndexer.php create mode 100644 lib/RoadizCoreBundle/src/SearchEngine/Indexer/Indexer.php create mode 100644 lib/RoadizCoreBundle/src/SearchEngine/Indexer/IndexerFactory.php create mode 100644 lib/RoadizCoreBundle/src/SearchEngine/Indexer/IndexerFactoryInterface.php create mode 100644 lib/RoadizCoreBundle/src/SearchEngine/Indexer/NodeIndexer.php create mode 100644 lib/RoadizCoreBundle/src/SearchEngine/Indexer/NodesSourcesIndexer.php create mode 100644 lib/RoadizCoreBundle/src/SearchEngine/Indexer/TagIndexer.php create mode 100644 lib/RoadizCoreBundle/src/SearchEngine/Message/AbstractSolrMessage.php create mode 100644 lib/RoadizCoreBundle/src/SearchEngine/Message/Handler/SolrDeleteMessageHandler.php create mode 100644 lib/RoadizCoreBundle/src/SearchEngine/Message/Handler/SolrReindexMessageHandler.php create mode 100644 lib/RoadizCoreBundle/src/SearchEngine/Message/SolrDeleteMessage.php create mode 100644 lib/RoadizCoreBundle/src/SearchEngine/Message/SolrReindexMessage.php create mode 100644 lib/RoadizCoreBundle/src/SearchEngine/NodeSourceSearchHandler.php create mode 100644 lib/RoadizCoreBundle/src/SearchEngine/NodeSourceSearchHandlerInterface.php create mode 100644 lib/RoadizCoreBundle/src/SearchEngine/SearchHandlerInterface.php create mode 100644 lib/RoadizCoreBundle/src/SearchEngine/SearchResultsInterface.php create mode 100644 lib/RoadizCoreBundle/src/SearchEngine/SolariumDocument.php create mode 100644 lib/RoadizCoreBundle/src/SearchEngine/SolariumDocumentTranslation.php create mode 100644 lib/RoadizCoreBundle/src/SearchEngine/SolariumFactory.php create mode 100644 lib/RoadizCoreBundle/src/SearchEngine/SolariumFactoryInterface.php create mode 100644 lib/RoadizCoreBundle/src/SearchEngine/SolariumNodeSource.php create mode 100644 lib/RoadizCoreBundle/src/SearchEngine/SolrSearchResults.php create mode 100644 lib/RoadizCoreBundle/src/SearchEngine/Subscriber/DefaultDocumentTranslationIndexingSubscriber.php create mode 100644 lib/RoadizCoreBundle/src/SearchEngine/Subscriber/DefaultNodesSourcesIndexingSubscriber.php create mode 100644 lib/RoadizCoreBundle/src/SearchEngine/Subscriber/SolariumSubscriber.php create mode 100644 lib/RoadizCoreBundle/src/Security/Authentication/JwtAuthenticationSuccessHandler.php create mode 100644 lib/RoadizCoreBundle/src/Security/Authentication/Manager/LoginAttemptManager.php create mode 100644 lib/RoadizCoreBundle/src/Security/Authentication/RoadizAuthenticator.php create mode 100644 lib/RoadizCoreBundle/src/Security/Authorization/AccessDeniedHandler.php create mode 100644 lib/RoadizCoreBundle/src/Security/Authorization/Chroot/NodeChrootChainResolver.php create mode 100644 lib/RoadizCoreBundle/src/Security/Authorization/Chroot/NodeChrootResolver.php create mode 100644 lib/RoadizCoreBundle/src/Security/Authorization/Chroot/RoadizUserNodeChrootResolver.php create mode 100644 lib/RoadizCoreBundle/src/Security/Authorization/Voter/GroupVoter.php create mode 100644 lib/RoadizCoreBundle/src/Security/Authorization/Voter/RealmVoter.php create mode 100644 lib/RoadizCoreBundle/src/Security/Authorization/Voter/RoleArrayVoter.php create mode 100644 lib/RoadizCoreBundle/src/Security/Authorization/Voter/SuperAdminRoleHierarchyVoter.php create mode 100644 lib/RoadizCoreBundle/src/Security/Blacklist/Top500Provider.php create mode 100644 lib/RoadizCoreBundle/src/Security/User/AdvancedUserInterface.php create mode 100644 lib/RoadizCoreBundle/src/Security/User/UserProvider.php create mode 100644 lib/RoadizCoreBundle/src/Security/User/UserViewer.php create mode 100644 lib/RoadizCoreBundle/src/Serializer/CircularReferenceHandler.php create mode 100644 lib/RoadizCoreBundle/src/Serializer/Normalizer/AbstractPathNormalizer.php create mode 100644 lib/RoadizCoreBundle/src/Serializer/Normalizer/AttributeValueNormalizer.php create mode 100644 lib/RoadizCoreBundle/src/Serializer/Normalizer/CustomFormNormalizer.php create mode 100644 lib/RoadizCoreBundle/src/Serializer/Normalizer/DocumentNormalizer.php create mode 100644 lib/RoadizCoreBundle/src/Serializer/Normalizer/DocumentSourcesNormalizer.php create mode 100644 lib/RoadizCoreBundle/src/Serializer/Normalizer/FolderNormalizer.php create mode 100644 lib/RoadizCoreBundle/src/Serializer/Normalizer/NodesSourcesPathNormalizer.php create mode 100644 lib/RoadizCoreBundle/src/Serializer/Normalizer/RealmSerializationGroupNormalizer.php create mode 100644 lib/RoadizCoreBundle/src/Serializer/Normalizer/TagNormalizer.php create mode 100644 lib/RoadizCoreBundle/src/Serializer/Normalizer/TranslationAwareNormalizer.php create mode 100644 lib/RoadizCoreBundle/src/Serializer/ObjectConstructor/AbstractTypedObjectConstructor.php create mode 100644 lib/RoadizCoreBundle/src/Serializer/ObjectConstructor/ChainDoctrineObjectConstructor.php create mode 100644 lib/RoadizCoreBundle/src/Serializer/ObjectConstructor/GroupObjectConstructor.php create mode 100644 lib/RoadizCoreBundle/src/Serializer/ObjectConstructor/NodeObjectConstructor.php create mode 100644 lib/RoadizCoreBundle/src/Serializer/ObjectConstructor/NodeTypeFieldObjectConstructor.php create mode 100644 lib/RoadizCoreBundle/src/Serializer/ObjectConstructor/NodeTypeObjectConstructor.php create mode 100644 lib/RoadizCoreBundle/src/Serializer/ObjectConstructor/ObjectConstructor.php create mode 100644 lib/RoadizCoreBundle/src/Serializer/ObjectConstructor/RoleObjectConstructor.php create mode 100644 lib/RoadizCoreBundle/src/Serializer/ObjectConstructor/SettingGroupObjectConstructor.php create mode 100644 lib/RoadizCoreBundle/src/Serializer/ObjectConstructor/SettingObjectConstructor.php create mode 100644 lib/RoadizCoreBundle/src/Serializer/ObjectConstructor/TagObjectConstructor.php create mode 100644 lib/RoadizCoreBundle/src/Serializer/ObjectConstructor/TranslationObjectConstructor.php create mode 100644 lib/RoadizCoreBundle/src/Serializer/ObjectConstructor/TypedObjectConstructorInterface.php create mode 100644 lib/RoadizCoreBundle/src/Serializer/TranslationAwareContextBuilder.php create mode 100644 lib/RoadizCoreBundle/src/Tag/TagFactory.php create mode 100644 lib/RoadizCoreBundle/src/Traits/LoginRequestTrait.php create mode 100644 lib/RoadizCoreBundle/src/Traits/LoginResetTrait.php create mode 100644 lib/RoadizCoreBundle/src/Translation/TranslationViewer.php create mode 100644 lib/RoadizCoreBundle/src/TwigExtension/AttributesExtension.php create mode 100644 lib/RoadizCoreBundle/src/TwigExtension/BlockRenderExtension.php create mode 100644 lib/RoadizCoreBundle/src/TwigExtension/CentralTruncateExtension.php create mode 100644 lib/RoadizCoreBundle/src/TwigExtension/DocumentUrlExtension.php create mode 100644 lib/RoadizCoreBundle/src/TwigExtension/HandlerExtension.php create mode 100644 lib/RoadizCoreBundle/src/TwigExtension/JwtExtension.php create mode 100644 lib/RoadizCoreBundle/src/TwigExtension/NodesSourcesExtension.php create mode 100644 lib/RoadizCoreBundle/src/TwigExtension/RoadizExtension.php create mode 100644 lib/RoadizCoreBundle/src/TwigExtension/RoutingExtension.php create mode 100644 lib/RoadizCoreBundle/src/TwigExtension/TokenParser/TransChoiceTokenParser.php create mode 100644 lib/RoadizCoreBundle/src/TwigExtension/TransChoiceExtension.php create mode 100644 lib/RoadizCoreBundle/src/TwigExtension/TranslationExtension.php create mode 100644 lib/RoadizCoreBundle/src/TwigExtension/TranslationMenuExtension.php create mode 100644 lib/RoadizCoreBundle/src/Webhook/Exception/TooManyWebhookTriggeredException.php create mode 100644 lib/RoadizCoreBundle/src/Webhook/Message/GenericJsonPostMessage.php create mode 100644 lib/RoadizCoreBundle/src/Webhook/Message/GitlabPipelineTriggerMessage.php create mode 100644 lib/RoadizCoreBundle/src/Webhook/Message/NetlifyBuildHookMessage.php create mode 100644 lib/RoadizCoreBundle/src/Webhook/Message/WebhookMessage.php create mode 100644 lib/RoadizCoreBundle/src/Webhook/Message/WebhookMessageFactory.php create mode 100644 lib/RoadizCoreBundle/src/Webhook/Message/WebhookMessageFactoryInterface.php create mode 100644 lib/RoadizCoreBundle/src/Webhook/ThrottledWebhookDispatcher.php create mode 100644 lib/RoadizCoreBundle/src/Webhook/WebhookDispatcher.php create mode 100644 lib/RoadizCoreBundle/src/Webhook/WebhookInterface.php create mode 100644 lib/RoadizCoreBundle/src/Workflow/Event/NodeStatusGuardListener.php create mode 100644 lib/RoadizCoreBundle/src/Workflow/NodeWorkflow.php create mode 100644 lib/RoadizCoreBundle/src/Xlsx/AbstractXlsxSerializer.php create mode 100644 lib/RoadizCoreBundle/src/Xlsx/NodeSourceXlsxSerializer.php create mode 100644 lib/RoadizCoreBundle/src/Xlsx/SerializerInterface.php create mode 100644 lib/RoadizCoreBundle/src/Xlsx/XlsxExporter.php create mode 100644 lib/RoadizCoreBundle/templates/ApiPlatformBundle/SwaggerUi/index.html.twig create mode 100644 lib/RoadizCoreBundle/templates/base.html.twig create mode 100644 lib/RoadizCoreBundle/templates/customForm/base_custom_form.html.twig create mode 100644 lib/RoadizCoreBundle/templates/customForm/customForm.html.twig create mode 100644 lib/RoadizCoreBundle/templates/customForm/customFormSent.html.twig create mode 100644 lib/RoadizCoreBundle/templates/customForm/customForms.html.twig create mode 100644 lib/RoadizCoreBundle/templates/email/404.html.twig create mode 100644 lib/RoadizCoreBundle/templates/email/base_email.html.twig create mode 100644 lib/RoadizCoreBundle/templates/email/base_email.txt.twig create mode 100644 lib/RoadizCoreBundle/templates/email/forms/answerForm.html.twig create mode 100644 lib/RoadizCoreBundle/templates/email/forms/answerForm.txt.twig create mode 100644 lib/RoadizCoreBundle/templates/email/forms/contactForm.html.twig create mode 100644 lib/RoadizCoreBundle/templates/email/forms/contactForm.txt.twig create mode 100644 lib/RoadizCoreBundle/templates/email/users/reset_password_email.html.twig create mode 100644 lib/RoadizCoreBundle/templates/email/users/reset_password_email.txt.twig create mode 100644 lib/RoadizCoreBundle/templates/email/users/welcome_user_email.html.twig create mode 100644 lib/RoadizCoreBundle/templates/email/users/welcome_user_email.txt.twig create mode 100644 lib/RoadizCoreBundle/templates/emerg.html create mode 100644 lib/RoadizCoreBundle/templates/fonts/fontfamily.css.twig create mode 100644 lib/RoadizCoreBundle/templates/nodeSource/default.html.twig create mode 100644 lib/RoadizCoreBundle/themes/.gitkeep create mode 100644 lib/RoadizCoreBundle/translations/core/messages.ar.xlf create mode 100644 lib/RoadizCoreBundle/translations/core/messages.de.xlf create mode 100644 lib/RoadizCoreBundle/translations/core/messages.en.xlf create mode 100644 lib/RoadizCoreBundle/translations/core/messages.es.xlf create mode 100644 lib/RoadizCoreBundle/translations/core/messages.fr.xlf create mode 100644 lib/RoadizCoreBundle/translations/core/messages.id.xlf create mode 100644 lib/RoadizCoreBundle/translations/core/messages.it.xlf create mode 100644 lib/RoadizCoreBundle/translations/core/messages.ru.xlf create mode 100644 lib/RoadizCoreBundle/translations/core/messages.sr.xlf create mode 100644 lib/RoadizCoreBundle/translations/core/messages.tr.xlf create mode 100644 lib/RoadizCoreBundle/translations/core/messages.uk.xlf create mode 100644 lib/RoadizCoreBundle/translations/core/messages.xlf create mode 100644 lib/RoadizCoreBundle/translations/core/messages.zh.xlf create mode 100644 lib/RoadizCoreBundle/translations/validators.ar.xlf create mode 100644 lib/RoadizCoreBundle/translations/validators.de.xlf create mode 100644 lib/RoadizCoreBundle/translations/validators.en.xlf create mode 100644 lib/RoadizCoreBundle/translations/validators.es.xlf create mode 100644 lib/RoadizCoreBundle/translations/validators.fr.xlf create mode 100644 lib/RoadizCoreBundle/translations/validators.id.xlf create mode 100644 lib/RoadizCoreBundle/translations/validators.it.xlf create mode 100644 lib/RoadizCoreBundle/translations/validators.ru.xlf create mode 100644 lib/RoadizCoreBundle/translations/validators.sr.xlf create mode 100644 lib/RoadizCoreBundle/translations/validators.tr.xlf create mode 100644 lib/RoadizCoreBundle/translations/validators.uk.xlf create mode 100644 lib/RoadizCoreBundle/translations/validators.xlf create mode 100644 lib/RoadizCoreBundle/translations/validators.zh.xlf delete mode 160000 lib/RoadizFontBundle create mode 100644 lib/RoadizFontBundle/.github/workflows/run-test.yml create mode 100644 lib/RoadizFontBundle/.gitignore create mode 100644 lib/RoadizFontBundle/.travis.yml create mode 100644 lib/RoadizFontBundle/LICENSE.md create mode 100644 lib/RoadizFontBundle/Makefile create mode 100644 lib/RoadizFontBundle/README.md create mode 100644 lib/RoadizFontBundle/composer.json create mode 100644 lib/RoadizFontBundle/config/fixtures.yaml create mode 100644 lib/RoadizFontBundle/config/fixtures/roles.json create mode 100644 lib/RoadizFontBundle/config/packages/doctrine.yaml create mode 100644 lib/RoadizFontBundle/config/packages/flysystem.yaml create mode 100644 lib/RoadizFontBundle/config/routing.yaml create mode 100644 lib/RoadizFontBundle/config/services.yaml create mode 100644 lib/RoadizFontBundle/migrations/Version20221015083114.php create mode 100644 lib/RoadizFontBundle/phpcs.xml.dist create mode 100644 lib/RoadizFontBundle/phpstan.neon create mode 100644 lib/RoadizFontBundle/src/Controller/Admin/FontsController.php create mode 100644 lib/RoadizFontBundle/src/Controller/FontFaceController.php create mode 100644 lib/RoadizFontBundle/src/DependencyInjection/Compiler/DoctrineMigrationCompilerPass.php create mode 100644 lib/RoadizFontBundle/src/DependencyInjection/RoadizFontExtension.php create mode 100644 lib/RoadizFontBundle/src/Doctrine/EventSubscriber/FontLifeCycleSubscriber.php create mode 100644 lib/RoadizFontBundle/src/Entity/Font.php create mode 100644 lib/RoadizFontBundle/src/Event/Font/FontEvent.php create mode 100644 lib/RoadizFontBundle/src/Event/Font/PreUpdatedFontEvent.php create mode 100644 lib/RoadizFontBundle/src/EventSubscriber/UpdateFontSubscriber.php create mode 100644 lib/RoadizFontBundle/src/Form/FontType.php create mode 100644 lib/RoadizFontBundle/src/Form/FontVariantsType.php create mode 100644 lib/RoadizFontBundle/src/Repository/FontRepository.php create mode 100644 lib/RoadizFontBundle/src/RoadizFontBundle.php create mode 100644 lib/RoadizFontBundle/templates/admin/actionsMenu.html.twig create mode 100644 lib/RoadizFontBundle/templates/admin/add.html.twig create mode 100644 lib/RoadizFontBundle/templates/admin/delete.html.twig create mode 100644 lib/RoadizFontBundle/templates/admin/edit.html.twig create mode 100644 lib/RoadizFontBundle/templates/admin/list.html.twig create mode 100644 lib/RoadizFontBundle/templates/fonts/fontfamily.css.twig delete mode 160000 lib/RoadizRozierBundle create mode 100644 lib/RoadizRozierBundle/.github/workflows/run-test.yml create mode 100644 lib/RoadizRozierBundle/.gitignore create mode 100644 lib/RoadizRozierBundle/.travis.yml create mode 100644 lib/RoadizRozierBundle/CHANGELOG.md create mode 100644 lib/RoadizRozierBundle/LICENSE.md create mode 100644 lib/RoadizRozierBundle/Makefile create mode 100644 lib/RoadizRozierBundle/README.md create mode 100644 lib/RoadizRozierBundle/composer.json create mode 100644 lib/RoadizRozierBundle/config/packages/roadiz_rozier.yaml create mode 100644 lib/RoadizRozierBundle/config/routing.yaml create mode 100644 lib/RoadizRozierBundle/config/routing/ajax.yml create mode 100644 lib/RoadizRozierBundle/config/routing/attributes.yml create mode 100644 lib/RoadizRozierBundle/config/routing/custom-form-answers.yml create mode 100644 lib/RoadizRozierBundle/config/routing/custom-forms-fields.yml create mode 100644 lib/RoadizRozierBundle/config/routing/custom-forms.yml create mode 100644 lib/RoadizRozierBundle/config/routing/documents.yml create mode 100644 lib/RoadizRozierBundle/config/routing/folders.yml create mode 100644 lib/RoadizRozierBundle/config/routing/groups.yml create mode 100644 lib/RoadizRozierBundle/config/routing/login.yml create mode 100644 lib/RoadizRozierBundle/config/routing/node-type-fields.yml create mode 100644 lib/RoadizRozierBundle/config/routing/node-types.yml create mode 100644 lib/RoadizRozierBundle/config/routing/nodes.yml create mode 100644 lib/RoadizRozierBundle/config/routing/realms.yml create mode 100644 lib/RoadizRozierBundle/config/routing/redirections.yml create mode 100644 lib/RoadizRozierBundle/config/routing/roles.yml create mode 100644 lib/RoadizRozierBundle/config/routing/setting-groups.yml create mode 100644 lib/RoadizRozierBundle/config/routing/settings.yml create mode 100644 lib/RoadizRozierBundle/config/routing/tags.yml create mode 100644 lib/RoadizRozierBundle/config/routing/translations.yml create mode 100644 lib/RoadizRozierBundle/config/routing/users.yml create mode 100644 lib/RoadizRozierBundle/config/routing/webhooks.yml create mode 100644 lib/RoadizRozierBundle/config/services.yaml create mode 100644 lib/RoadizRozierBundle/crowdin.yml create mode 100644 lib/RoadizRozierBundle/deprecated.php create mode 100644 lib/RoadizRozierBundle/phpcs.xml.dist create mode 100644 lib/RoadizRozierBundle/phpstan.neon create mode 100644 lib/RoadizRozierBundle/src/Aliases.php create mode 100644 lib/RoadizRozierBundle/src/Controller/BackendController.php create mode 100644 lib/RoadizRozierBundle/src/Controller/CustomForm/CustomFormUsageController.php create mode 100644 lib/RoadizRozierBundle/src/Controller/Document/DocumentArchiveController.php create mode 100644 lib/RoadizRozierBundle/src/Controller/Document/DocumentDuplicatesController.php create mode 100644 lib/RoadizRozierBundle/src/Controller/Document/DocumentLimitationsController.php create mode 100644 lib/RoadizRozierBundle/src/Controller/Document/DocumentPreviewController.php create mode 100644 lib/RoadizRozierBundle/src/Controller/Document/DocumentPrivateListController.php create mode 100644 lib/RoadizRozierBundle/src/Controller/Document/DocumentPublicListController.php create mode 100644 lib/RoadizRozierBundle/src/Controller/Document/DocumentUnusedController.php create mode 100644 lib/RoadizRozierBundle/src/Controller/Login/LoginRequestController.php create mode 100644 lib/RoadizRozierBundle/src/Controller/Node/NodesTagsController.php create mode 100644 lib/RoadizRozierBundle/src/Controller/Node/RealmNodeController.php create mode 100644 lib/RoadizRozierBundle/src/Controller/Node/SeoController.php create mode 100644 lib/RoadizRozierBundle/src/Controller/Node/TranslateController.php create mode 100644 lib/RoadizRozierBundle/src/Controller/PingController.php create mode 100644 lib/RoadizRozierBundle/src/Controller/Realm/RealmController.php create mode 100644 lib/RoadizRozierBundle/src/Controller/SecurityController.php create mode 100644 lib/RoadizRozierBundle/src/DependencyInjection/Compiler/JwtRoleStrategyCompilerPass.php create mode 100644 lib/RoadizRozierBundle/src/DependencyInjection/Compiler/RozierPathsCompilerPass.php create mode 100644 lib/RoadizRozierBundle/src/DependencyInjection/Configuration.php create mode 100644 lib/RoadizRozierBundle/src/DependencyInjection/RoadizRozierExtension.php create mode 100644 lib/RoadizRozierBundle/src/Form/CustomFormType.php create mode 100644 lib/RoadizRozierBundle/src/Form/DataTransformer/NodesTagsTransformer.php create mode 100644 lib/RoadizRozierBundle/src/Form/DocumentLimitationsType.php create mode 100644 lib/RoadizRozierBundle/src/Form/DocumentTranslationType.php create mode 100644 lib/RoadizRozierBundle/src/Form/NodesTagsType.php create mode 100644 lib/RoadizRozierBundle/src/Form/TranslateNodeType.php create mode 100644 lib/RoadizRozierBundle/src/ListManager/SessionListFilters.php create mode 100644 lib/RoadizRozierBundle/src/RoadizRozierBundle.php create mode 100644 lib/RoadizRozierBundle/src/Security/RozierAuthenticator.php create mode 100644 lib/RoadizRozierBundle/templates/custom-forms/navBar.html.twig create mode 100644 lib/RoadizRozierBundle/templates/custom-forms/usage.html.twig create mode 100644 lib/RoadizRozierBundle/templates/documents/duplicated.html.twig create mode 100644 lib/RoadizRozierBundle/templates/documents/limitations.html.twig create mode 100644 lib/RoadizRozierBundle/templates/documents/list-table.html.twig create mode 100644 lib/RoadizRozierBundle/templates/documents/list.html.twig create mode 100644 lib/RoadizRozierBundle/templates/documents/navBar.html.twig create mode 100644 lib/RoadizRozierBundle/templates/documents/preview.html.twig create mode 100644 lib/RoadizRozierBundle/templates/documents/unused.html.twig create mode 100644 lib/RoadizRozierBundle/templates/folders/navBar.html.twig create mode 100644 lib/RoadizRozierBundle/templates/nodes/actionsMenu.html.twig create mode 100644 lib/RoadizRozierBundle/templates/nodes/deleteRealm.html.twig create mode 100644 lib/RoadizRozierBundle/templates/nodes/editAliases.html.twig create mode 100644 lib/RoadizRozierBundle/templates/nodes/navBar.html.twig create mode 100644 lib/RoadizRozierBundle/templates/nodes/realms.html.twig create mode 100644 lib/RoadizRozierBundle/templates/realms/actionsMenu.html.twig create mode 100644 lib/RoadizRozierBundle/templates/realms/add.html.twig create mode 100644 lib/RoadizRozierBundle/templates/realms/delete.html.twig create mode 100644 lib/RoadizRozierBundle/templates/realms/edit.html.twig create mode 100644 lib/RoadizRozierBundle/templates/realms/list.html.twig create mode 100644 lib/RoadizRozierBundle/templates/security/login.html.twig create mode 100644 lib/RoadizRozierBundle/templates/simple.html.twig create mode 100644 lib/RoadizRozierBundle/templates/users/list.html.twig create mode 100644 lib/RoadizRozierBundle/translations/attributes/messages.ar.xlf create mode 100644 lib/RoadizRozierBundle/translations/attributes/messages.de.xlf create mode 100644 lib/RoadizRozierBundle/translations/attributes/messages.en.xlf create mode 100644 lib/RoadizRozierBundle/translations/attributes/messages.es.xlf create mode 100644 lib/RoadizRozierBundle/translations/attributes/messages.fr.xlf create mode 100644 lib/RoadizRozierBundle/translations/attributes/messages.id.xlf create mode 100644 lib/RoadizRozierBundle/translations/attributes/messages.it.xlf create mode 100644 lib/RoadizRozierBundle/translations/attributes/messages.ru.xlf create mode 100644 lib/RoadizRozierBundle/translations/attributes/messages.sr.xlf create mode 100644 lib/RoadizRozierBundle/translations/attributes/messages.tr.xlf create mode 100644 lib/RoadizRozierBundle/translations/attributes/messages.uk.xlf create mode 100644 lib/RoadizRozierBundle/translations/attributes/messages.xlf create mode 100644 lib/RoadizRozierBundle/translations/attributes/messages.zh.xlf create mode 100644 lib/RoadizRozierBundle/translations/custom-forms/messages.ar.xlf create mode 100644 lib/RoadizRozierBundle/translations/custom-forms/messages.de.xlf create mode 100644 lib/RoadizRozierBundle/translations/custom-forms/messages.en.xlf create mode 100644 lib/RoadizRozierBundle/translations/custom-forms/messages.es.xlf create mode 100644 lib/RoadizRozierBundle/translations/custom-forms/messages.fr.xlf create mode 100644 lib/RoadizRozierBundle/translations/custom-forms/messages.id.xlf create mode 100644 lib/RoadizRozierBundle/translations/custom-forms/messages.it.xlf create mode 100644 lib/RoadizRozierBundle/translations/custom-forms/messages.ru.xlf create mode 100644 lib/RoadizRozierBundle/translations/custom-forms/messages.sr.xlf create mode 100644 lib/RoadizRozierBundle/translations/custom-forms/messages.tr.xlf create mode 100644 lib/RoadizRozierBundle/translations/custom-forms/messages.uk.xlf create mode 100644 lib/RoadizRozierBundle/translations/custom-forms/messages.xlf create mode 100644 lib/RoadizRozierBundle/translations/custom-forms/messages.zh.xlf create mode 100644 lib/RoadizRozierBundle/translations/documents/messages.ar.xlf create mode 100644 lib/RoadizRozierBundle/translations/documents/messages.de.xlf create mode 100644 lib/RoadizRozierBundle/translations/documents/messages.en.xlf create mode 100644 lib/RoadizRozierBundle/translations/documents/messages.es.xlf create mode 100644 lib/RoadizRozierBundle/translations/documents/messages.fr.xlf create mode 100644 lib/RoadizRozierBundle/translations/documents/messages.id.xlf create mode 100644 lib/RoadizRozierBundle/translations/documents/messages.it.xlf create mode 100644 lib/RoadizRozierBundle/translations/documents/messages.ru.xlf create mode 100644 lib/RoadizRozierBundle/translations/documents/messages.sr.xlf create mode 100644 lib/RoadizRozierBundle/translations/documents/messages.tr.xlf create mode 100644 lib/RoadizRozierBundle/translations/documents/messages.uk.xlf create mode 100644 lib/RoadizRozierBundle/translations/documents/messages.xlf create mode 100644 lib/RoadizRozierBundle/translations/documents/messages.zh.xlf create mode 100644 lib/RoadizRozierBundle/translations/helps/messages.ar.xlf create mode 100644 lib/RoadizRozierBundle/translations/helps/messages.de.xlf create mode 100644 lib/RoadizRozierBundle/translations/helps/messages.en.xlf create mode 100644 lib/RoadizRozierBundle/translations/helps/messages.es.xlf create mode 100644 lib/RoadizRozierBundle/translations/helps/messages.fr.xlf create mode 100644 lib/RoadizRozierBundle/translations/helps/messages.id.xlf create mode 100644 lib/RoadizRozierBundle/translations/helps/messages.it.xlf create mode 100644 lib/RoadizRozierBundle/translations/helps/messages.ru.xlf create mode 100644 lib/RoadizRozierBundle/translations/helps/messages.sr.xlf create mode 100644 lib/RoadizRozierBundle/translations/helps/messages.tr.xlf create mode 100644 lib/RoadizRozierBundle/translations/helps/messages.uk.xlf create mode 100644 lib/RoadizRozierBundle/translations/helps/messages.xlf create mode 100644 lib/RoadizRozierBundle/translations/helps/messages.zh.xlf create mode 100644 lib/RoadizRozierBundle/translations/realms/messages.ar.xlf create mode 100644 lib/RoadizRozierBundle/translations/realms/messages.de.xlf create mode 100644 lib/RoadizRozierBundle/translations/realms/messages.en.xlf create mode 100644 lib/RoadizRozierBundle/translations/realms/messages.es.xlf create mode 100644 lib/RoadizRozierBundle/translations/realms/messages.fr.xlf create mode 100644 lib/RoadizRozierBundle/translations/realms/messages.id.xlf create mode 100644 lib/RoadizRozierBundle/translations/realms/messages.it.xlf create mode 100644 lib/RoadizRozierBundle/translations/realms/messages.ru.xlf create mode 100644 lib/RoadizRozierBundle/translations/realms/messages.sr.xlf create mode 100644 lib/RoadizRozierBundle/translations/realms/messages.tr.xlf create mode 100644 lib/RoadizRozierBundle/translations/realms/messages.uk.xlf create mode 100644 lib/RoadizRozierBundle/translations/realms/messages.xlf create mode 100644 lib/RoadizRozierBundle/translations/realms/messages.zh.xlf create mode 100644 lib/RoadizRozierBundle/translations/redirections/messages.ar.xlf create mode 100644 lib/RoadizRozierBundle/translations/redirections/messages.de.xlf create mode 100644 lib/RoadizRozierBundle/translations/redirections/messages.en.xlf create mode 100644 lib/RoadizRozierBundle/translations/redirections/messages.es.xlf create mode 100644 lib/RoadizRozierBundle/translations/redirections/messages.fr.xlf create mode 100644 lib/RoadizRozierBundle/translations/redirections/messages.id.xlf create mode 100644 lib/RoadizRozierBundle/translations/redirections/messages.it.xlf create mode 100644 lib/RoadizRozierBundle/translations/redirections/messages.ru.xlf create mode 100644 lib/RoadizRozierBundle/translations/redirections/messages.sr.xlf create mode 100644 lib/RoadizRozierBundle/translations/redirections/messages.tr.xlf create mode 100644 lib/RoadizRozierBundle/translations/redirections/messages.uk.xlf create mode 100644 lib/RoadizRozierBundle/translations/redirections/messages.xlf create mode 100644 lib/RoadizRozierBundle/translations/redirections/messages.zh.xlf create mode 100644 lib/RoadizRozierBundle/translations/settings/messages.ar.xlf create mode 100644 lib/RoadizRozierBundle/translations/settings/messages.de.xlf create mode 100644 lib/RoadizRozierBundle/translations/settings/messages.en.xlf create mode 100644 lib/RoadizRozierBundle/translations/settings/messages.es.xlf create mode 100644 lib/RoadizRozierBundle/translations/settings/messages.fr.xlf create mode 100644 lib/RoadizRozierBundle/translations/settings/messages.id.xlf create mode 100644 lib/RoadizRozierBundle/translations/settings/messages.it.xlf create mode 100644 lib/RoadizRozierBundle/translations/settings/messages.ru.xlf create mode 100644 lib/RoadizRozierBundle/translations/settings/messages.sr.xlf create mode 100644 lib/RoadizRozierBundle/translations/settings/messages.tr.xlf create mode 100644 lib/RoadizRozierBundle/translations/settings/messages.uk.xlf create mode 100644 lib/RoadizRozierBundle/translations/settings/messages.xlf create mode 100644 lib/RoadizRozierBundle/translations/settings/messages.zh.xlf create mode 100644 lib/RoadizRozierBundle/translations/users/messages.ar.xlf create mode 100644 lib/RoadizRozierBundle/translations/users/messages.de.xlf create mode 100644 lib/RoadizRozierBundle/translations/users/messages.en.xlf create mode 100644 lib/RoadizRozierBundle/translations/users/messages.es.xlf create mode 100644 lib/RoadizRozierBundle/translations/users/messages.fr.xlf create mode 100644 lib/RoadizRozierBundle/translations/users/messages.id.xlf create mode 100644 lib/RoadizRozierBundle/translations/users/messages.it.xlf create mode 100644 lib/RoadizRozierBundle/translations/users/messages.ru.xlf create mode 100644 lib/RoadizRozierBundle/translations/users/messages.sr.xlf create mode 100644 lib/RoadizRozierBundle/translations/users/messages.tr.xlf create mode 100644 lib/RoadizRozierBundle/translations/users/messages.uk.xlf create mode 100644 lib/RoadizRozierBundle/translations/users/messages.xlf create mode 100644 lib/RoadizRozierBundle/translations/users/messages.zh.xlf create mode 100644 lib/RoadizRozierBundle/translations/webhooks/messages.ar.xlf create mode 100644 lib/RoadizRozierBundle/translations/webhooks/messages.de.xlf create mode 100644 lib/RoadizRozierBundle/translations/webhooks/messages.en.xlf create mode 100644 lib/RoadizRozierBundle/translations/webhooks/messages.es.xlf create mode 100644 lib/RoadizRozierBundle/translations/webhooks/messages.fr.xlf create mode 100644 lib/RoadizRozierBundle/translations/webhooks/messages.id.xlf create mode 100644 lib/RoadizRozierBundle/translations/webhooks/messages.it.xlf create mode 100644 lib/RoadizRozierBundle/translations/webhooks/messages.ru.xlf create mode 100644 lib/RoadizRozierBundle/translations/webhooks/messages.sr.xlf create mode 100644 lib/RoadizRozierBundle/translations/webhooks/messages.tr.xlf create mode 100644 lib/RoadizRozierBundle/translations/webhooks/messages.uk.xlf create mode 100644 lib/RoadizRozierBundle/translations/webhooks/messages.xlf create mode 100644 lib/RoadizRozierBundle/translations/webhooks/messages.zh.xlf delete mode 160000 lib/RoadizUserBundle create mode 100644 lib/RoadizUserBundle/.github/workflows/run-test.yml create mode 100644 lib/RoadizUserBundle/.gitignore create mode 100644 lib/RoadizUserBundle/.travis.yml create mode 100644 lib/RoadizUserBundle/LICENSE.md create mode 100644 lib/RoadizUserBundle/Makefile create mode 100644 lib/RoadizUserBundle/README.md create mode 100644 lib/RoadizUserBundle/composer.json create mode 100644 lib/RoadizUserBundle/config/api_resources/user.yaml create mode 100644 lib/RoadizUserBundle/config/packages/roadiz_user.yaml create mode 100644 lib/RoadizUserBundle/config/routing.yaml create mode 100644 lib/RoadizUserBundle/config/services.yaml create mode 100644 lib/RoadizUserBundle/crowdin.yml create mode 100644 lib/RoadizUserBundle/migrations/Version20220613144815.php create mode 100644 lib/RoadizUserBundle/migrations/Version20220615142220.php create mode 100644 lib/RoadizUserBundle/migrations/Version20220718100618.php create mode 100644 lib/RoadizUserBundle/phpcs.xml.dist create mode 100644 lib/RoadizUserBundle/phpstan.neon create mode 100644 lib/RoadizUserBundle/src/Api/DataTransformer/UserInputDataTransformer.php create mode 100644 lib/RoadizUserBundle/src/Api/DataTransformer/UserOutputDataTransformer.php create mode 100644 lib/RoadizUserBundle/src/Api/DataTransformer/UserPasswordRequestInputDataTransformer.php create mode 100644 lib/RoadizUserBundle/src/Api/DataTransformer/UserPasswordTokenInputDataTransformer.php create mode 100644 lib/RoadizUserBundle/src/Api/DataTransformer/UserTokenInputDataTransformer.php create mode 100644 lib/RoadizUserBundle/src/Api/DataTransformer/UserValidationRequestInputDataTransformer.php create mode 100644 lib/RoadizUserBundle/src/Api/DataTransformer/VoidOutputDataTransformer.php create mode 100644 lib/RoadizUserBundle/src/Api/Dto/UserInput.php create mode 100644 lib/RoadizUserBundle/src/Api/Dto/UserOutput.php create mode 100644 lib/RoadizUserBundle/src/Api/Dto/UserPasswordRequestInput.php create mode 100644 lib/RoadizUserBundle/src/Api/Dto/UserPasswordTokenInput.php create mode 100644 lib/RoadizUserBundle/src/Api/Dto/UserTokenInput.php create mode 100644 lib/RoadizUserBundle/src/Api/Dto/UserValidationRequestInput.php create mode 100644 lib/RoadizUserBundle/src/Api/Dto/VoidOutput.php create mode 100644 lib/RoadizUserBundle/src/Console/PurgeUserValidationTokenCommand.php create mode 100644 lib/RoadizUserBundle/src/Controller/InformationController.php create mode 100644 lib/RoadizUserBundle/src/Controller/PasswordRequestController.php create mode 100644 lib/RoadizUserBundle/src/Controller/PasswordResetController.php create mode 100644 lib/RoadizUserBundle/src/Controller/RecaptchaProtectedControllerTrait.php create mode 100644 lib/RoadizUserBundle/src/Controller/SignupController.php create mode 100644 lib/RoadizUserBundle/src/Controller/ValidateController.php create mode 100644 lib/RoadizUserBundle/src/Controller/ValidationRequestController.php create mode 100644 lib/RoadizUserBundle/src/DependencyInjection/Compiler/DoctrineMigrationCompilerPass.php create mode 100644 lib/RoadizUserBundle/src/DependencyInjection/Configuration.php create mode 100644 lib/RoadizUserBundle/src/DependencyInjection/RoadizUserExtension.php create mode 100644 lib/RoadizUserBundle/src/Entity/UserMetadata.php create mode 100644 lib/RoadizUserBundle/src/Entity/UserValidationToken.php create mode 100644 lib/RoadizUserBundle/src/Event/UserEmailValidated.php create mode 100644 lib/RoadizUserBundle/src/Event/UserSignedUp.php create mode 100644 lib/RoadizUserBundle/src/EventSubscriber/UserSignedUpSubscriber.php create mode 100644 lib/RoadizUserBundle/src/Manager/UserMetadataManager.php create mode 100644 lib/RoadizUserBundle/src/Manager/UserMetadataManagerInterface.php create mode 100644 lib/RoadizUserBundle/src/Manager/UserValidationTokenManager.php create mode 100644 lib/RoadizUserBundle/src/Manager/UserValidationTokenManagerInterface.php create mode 100644 lib/RoadizUserBundle/src/Repository/UserValidationTokenRepository.php create mode 100644 lib/RoadizUserBundle/src/RoadizUserBundle.php create mode 100644 lib/RoadizUserBundle/templates/email/users/reset_password_email.html.twig create mode 100644 lib/RoadizUserBundle/templates/email/users/reset_password_email.txt.twig create mode 100644 lib/RoadizUserBundle/templates/email/users/validate_email.html.twig create mode 100644 lib/RoadizUserBundle/templates/email/users/validate_email.txt.twig create mode 100644 lib/RoadizUserBundle/translations/email/messages.ar.xlf create mode 100644 lib/RoadizUserBundle/translations/email/messages.de.xlf create mode 100644 lib/RoadizUserBundle/translations/email/messages.en.xlf create mode 100644 lib/RoadizUserBundle/translations/email/messages.es.xlf create mode 100644 lib/RoadizUserBundle/translations/email/messages.fr.xlf create mode 100644 lib/RoadizUserBundle/translations/email/messages.id.xlf create mode 100644 lib/RoadizUserBundle/translations/email/messages.it.xlf create mode 100644 lib/RoadizUserBundle/translations/email/messages.ru.xlf create mode 100644 lib/RoadizUserBundle/translations/email/messages.sr.xlf create mode 100644 lib/RoadizUserBundle/translations/email/messages.tr.xlf create mode 100644 lib/RoadizUserBundle/translations/email/messages.uk.xlf create mode 100644 lib/RoadizUserBundle/translations/email/messages.xlf create mode 100644 lib/RoadizUserBundle/translations/email/messages.zh.xlf delete mode 160000 lib/Rozier create mode 100644 lib/Rozier/.editorconfig create mode 100644 lib/Rozier/.github/workflows/run-test.yml create mode 100644 lib/Rozier/.gitignore create mode 100644 lib/Rozier/.travis.yml create mode 100644 lib/Rozier/.travis/backoffice_assets.sh create mode 100644 lib/Rozier/.travis/composer_install.sh create mode 100644 lib/Rozier/.travis/php_lint.sh create mode 100644 lib/Rozier/CHANGELOG.md create mode 100644 lib/Rozier/LICENSE.md create mode 100644 lib/Rozier/Makefile create mode 100644 lib/Rozier/README.md create mode 100644 lib/Rozier/composer.json create mode 100644 lib/Rozier/crowdin.yml create mode 100644 lib/Rozier/phpcs.xml.dist create mode 100644 lib/Rozier/phpstan.neon create mode 100644 lib/Rozier/src/.babelrc create mode 100644 lib/Rozier/src/.editorconfig create mode 100644 lib/Rozier/src/.eslintignore create mode 100644 lib/Rozier/src/.eslintrc.js create mode 100644 lib/Rozier/src/.gitignore create mode 100644 lib/Rozier/src/.postcssrc.js create mode 100644 lib/Rozier/src/.prettierrc.js create mode 100644 lib/Rozier/src/AjaxControllers/AbstractAjaxController.php create mode 100644 lib/Rozier/src/AjaxControllers/AjaxAbstractFieldsController.php create mode 100644 lib/Rozier/src/AjaxControllers/AjaxAttributeValuesController.php create mode 100644 lib/Rozier/src/AjaxControllers/AjaxCustomFormFieldsController.php create mode 100644 lib/Rozier/src/AjaxControllers/AjaxCustomFormsExplorerController.php create mode 100644 lib/Rozier/src/AjaxControllers/AjaxDocumentsExplorerController.php create mode 100644 lib/Rozier/src/AjaxControllers/AjaxEntitiesExplorerController.php create mode 100644 lib/Rozier/src/AjaxControllers/AjaxExplorerProviderController.php create mode 100644 lib/Rozier/src/AjaxControllers/AjaxFolderTreeController.php create mode 100644 lib/Rozier/src/AjaxControllers/AjaxFoldersController.php create mode 100644 lib/Rozier/src/AjaxControllers/AjaxFoldersExplorerController.php create mode 100644 lib/Rozier/src/AjaxControllers/AjaxNodeTreeController.php create mode 100644 lib/Rozier/src/AjaxControllers/AjaxNodeTypeFieldsController.php create mode 100644 lib/Rozier/src/AjaxControllers/AjaxNodeTypesController.php create mode 100644 lib/Rozier/src/AjaxControllers/AjaxNodesController.php create mode 100644 lib/Rozier/src/AjaxControllers/AjaxNodesExplorerController.php create mode 100644 lib/Rozier/src/AjaxControllers/AjaxSearchNodesSourcesController.php create mode 100644 lib/Rozier/src/AjaxControllers/AjaxSessionMessages.php create mode 100644 lib/Rozier/src/AjaxControllers/AjaxTagTreeController.php create mode 100644 lib/Rozier/src/AjaxControllers/AjaxTagsController.php create mode 100644 lib/Rozier/src/Controllers/AbstractAdminController.php create mode 100644 lib/Rozier/src/Controllers/Attributes/AttributeController.php create mode 100644 lib/Rozier/src/Controllers/Attributes/AttributeGroupController.php create mode 100644 lib/Rozier/src/Controllers/CacheController.php create mode 100644 lib/Rozier/src/Controllers/CustomForms/CustomFormAnswersController.php create mode 100644 lib/Rozier/src/Controllers/CustomForms/CustomFormFieldAttributesController.php create mode 100644 lib/Rozier/src/Controllers/CustomForms/CustomFormFieldsController.php create mode 100644 lib/Rozier/src/Controllers/CustomForms/CustomFormsController.php create mode 100644 lib/Rozier/src/Controllers/CustomForms/CustomFormsUtilsController.php create mode 100644 lib/Rozier/src/Controllers/DashboardController.php create mode 100644 lib/Rozier/src/Controllers/Documents/DocumentTranslationsController.php create mode 100644 lib/Rozier/src/Controllers/Documents/DocumentsController.php create mode 100644 lib/Rozier/src/Controllers/FoldersController.php create mode 100644 lib/Rozier/src/Controllers/GroupsController.php create mode 100644 lib/Rozier/src/Controllers/GroupsUtilsController.php create mode 100644 lib/Rozier/src/Controllers/HistoryController.php create mode 100644 lib/Rozier/src/Controllers/LoginController.php create mode 100644 lib/Rozier/src/Controllers/LoginResetController.php create mode 100644 lib/Rozier/src/Controllers/NodeTypeFieldsController.php create mode 100644 lib/Rozier/src/Controllers/NodeTypes/NodeTypesController.php create mode 100644 lib/Rozier/src/Controllers/NodeTypes/NodeTypesUtilsController.php create mode 100644 lib/Rozier/src/Controllers/Nodes/ExportController.php create mode 100644 lib/Rozier/src/Controllers/Nodes/HistoryController.php create mode 100644 lib/Rozier/src/Controllers/Nodes/NodesAttributesController.php create mode 100644 lib/Rozier/src/Controllers/Nodes/NodesController.php create mode 100644 lib/Rozier/src/Controllers/Nodes/NodesSourcesController.php create mode 100644 lib/Rozier/src/Controllers/Nodes/NodesTagsController.php create mode 100644 lib/Rozier/src/Controllers/Nodes/NodesTreesController.php create mode 100644 lib/Rozier/src/Controllers/Nodes/NodesUtilsController.php create mode 100644 lib/Rozier/src/Controllers/Nodes/TranstypeController.php create mode 100644 lib/Rozier/src/Controllers/Nodes/UrlAliasesController.php create mode 100644 lib/Rozier/src/Controllers/PingController.php create mode 100644 lib/Rozier/src/Controllers/RedirectionsController.php create mode 100644 lib/Rozier/src/Controllers/RolesController.php create mode 100644 lib/Rozier/src/Controllers/RolesUtilsController.php create mode 100644 lib/Rozier/src/Controllers/SearchController.php create mode 100644 lib/Rozier/src/Controllers/SettingGroupsController.php create mode 100644 lib/Rozier/src/Controllers/SettingsController.php create mode 100644 lib/Rozier/src/Controllers/SettingsUtilsController.php create mode 100644 lib/Rozier/src/Controllers/Tags/TagMultiCreationController.php create mode 100644 lib/Rozier/src/Controllers/Tags/TagsController.php create mode 100644 lib/Rozier/src/Controllers/Tags/TagsUtilsController.php create mode 100644 lib/Rozier/src/Controllers/TranslationsController.php create mode 100644 lib/Rozier/src/Controllers/Users/UsersController.php create mode 100644 lib/Rozier/src/Controllers/Users/UsersGroupsController.php create mode 100644 lib/Rozier/src/Controllers/Users/UsersRolesController.php create mode 100644 lib/Rozier/src/Controllers/Users/UsersSecurityController.php create mode 100644 lib/Rozier/src/Controllers/WebhookController.php create mode 100644 lib/Rozier/src/Explorer/ConfigurableExplorerItem.php create mode 100644 lib/Rozier/src/Explorer/FolderExplorerItem.php create mode 100644 lib/Rozier/src/Explorer/FoldersProvider.php create mode 100644 lib/Rozier/src/Explorer/SettingExplorerItem.php create mode 100644 lib/Rozier/src/Explorer/SettingsProvider.php create mode 100644 lib/Rozier/src/Explorer/UserExplorerItem.php create mode 100644 lib/Rozier/src/Explorer/UsersProvider.php create mode 100644 lib/Rozier/src/Forms/AddUserType.php create mode 100644 lib/Rozier/src/Forms/CustomFormFieldType.php create mode 100644 lib/Rozier/src/Forms/DataTransformer/TagTransformer.php create mode 100644 lib/Rozier/src/Forms/DocumentEditType.php create mode 100644 lib/Rozier/src/Forms/DocumentEmbedType.php create mode 100644 lib/Rozier/src/Forms/DocumentTranslationType.php create mode 100644 lib/Rozier/src/Forms/DynamicType.php create mode 100644 lib/Rozier/src/Forms/FolderCollectionType.php create mode 100644 lib/Rozier/src/Forms/FolderTranslationType.php create mode 100644 lib/Rozier/src/Forms/FolderType.php create mode 100644 lib/Rozier/src/Forms/GeoJsonType.php create mode 100644 lib/Rozier/src/Forms/GroupType.php create mode 100644 lib/Rozier/src/Forms/LoginType.php create mode 100644 lib/Rozier/src/Forms/MultiTagType.php create mode 100644 lib/Rozier/src/Forms/Node/AddNodeType.php create mode 100644 lib/Rozier/src/Forms/NodeSource/AbstractConfigurableNodeSourceFieldType.php create mode 100644 lib/Rozier/src/Forms/NodeSource/AbstractNodeSourceFieldType.php create mode 100644 lib/Rozier/src/Forms/NodeSource/NodeSourceBaseType.php create mode 100644 lib/Rozier/src/Forms/NodeSource/NodeSourceCollectionType.php create mode 100644 lib/Rozier/src/Forms/NodeSource/NodeSourceCustomFormType.php create mode 100644 lib/Rozier/src/Forms/NodeSource/NodeSourceDocumentType.php create mode 100644 lib/Rozier/src/Forms/NodeSource/NodeSourceJoinType.php create mode 100644 lib/Rozier/src/Forms/NodeSource/NodeSourceNodeType.php create mode 100644 lib/Rozier/src/Forms/NodeSource/NodeSourceProviderType.php create mode 100644 lib/Rozier/src/Forms/NodeSource/NodeSourceSeoType.php create mode 100644 lib/Rozier/src/Forms/NodeSource/NodeSourceType.php create mode 100644 lib/Rozier/src/Forms/NodeTagsType.php create mode 100644 lib/Rozier/src/Forms/NodeTreeType.php create mode 100644 lib/Rozier/src/Forms/NodeType.php create mode 100644 lib/Rozier/src/Forms/NodeTypeFieldSerializationType.php create mode 100644 lib/Rozier/src/Forms/NodeTypeFieldType.php create mode 100644 lib/Rozier/src/Forms/NodeTypeType.php create mode 100644 lib/Rozier/src/Forms/RedirectionType.php create mode 100644 lib/Rozier/src/Forms/RoleType.php create mode 100644 lib/Rozier/src/Forms/SettingGroupType.php create mode 100644 lib/Rozier/src/Forms/TagTranslationType.php create mode 100644 lib/Rozier/src/Forms/TagType.php create mode 100644 lib/Rozier/src/Forms/TranslationType.php create mode 100644 lib/Rozier/src/Forms/TranstypeType.php create mode 100644 lib/Rozier/src/Forms/UserDetailsType.php create mode 100644 lib/Rozier/src/Forms/UserSecurityType.php create mode 100644 lib/Rozier/src/Forms/UserType.php create mode 100644 lib/Rozier/src/Models/CustomFormModel.php create mode 100644 lib/Rozier/src/Models/DocumentModel.php create mode 100644 lib/Rozier/src/Models/ModelInterface.php create mode 100644 lib/Rozier/src/Models/NodeModel.php create mode 100644 lib/Rozier/src/Models/NodeSourceModel.php create mode 100644 lib/Rozier/src/Models/NodeTypeModel.php create mode 100644 lib/Rozier/src/Models/TagModel.php create mode 100644 lib/Rozier/src/Resources/app/App.js create mode 100644 lib/Rozier/src/Resources/app/Lazyload.js create mode 100644 lib/Rozier/src/Resources/app/Rozier.js create mode 100644 lib/Rozier/src/Resources/app/RozierMobile.js create mode 100644 lib/Rozier/src/Resources/app/api/CustomFormApi.js create mode 100644 lib/Rozier/src/Resources/app/api/DocumentApi.js create mode 100644 lib/Rozier/src/Resources/app/api/DrawerApi.js create mode 100644 lib/Rozier/src/Resources/app/api/ExplorerApi.js create mode 100644 lib/Rozier/src/Resources/app/api/ExplorerProviderApi.js create mode 100644 lib/Rozier/src/Resources/app/api/FilterExplorerApi.js create mode 100644 lib/Rozier/src/Resources/app/api/FolderExplorerApi.js create mode 100644 lib/Rozier/src/Resources/app/api/JoinApi.js create mode 100644 lib/Rozier/src/Resources/app/api/NodeApi.js create mode 100644 lib/Rozier/src/Resources/app/api/NodeTypeApi.js create mode 100644 lib/Rozier/src/Resources/app/api/NodesSourceSearchApi.js create mode 100644 lib/Rozier/src/Resources/app/api/SplashScreenApi.js create mode 100644 lib/Rozier/src/Resources/app/api/TagApi.js create mode 100644 lib/Rozier/src/Resources/app/api/TagExplorerApi.js create mode 100644 lib/Rozier/src/Resources/app/api/index.js create mode 100644 lib/Rozier/src/Resources/app/assets/img/apple-touch-icon-114x114.png create mode 100644 lib/Rozier/src/Resources/app/assets/img/apple-touch-icon-120x120.png create mode 100644 lib/Rozier/src/Resources/app/assets/img/apple-touch-icon-144x144.png create mode 100644 lib/Rozier/src/Resources/app/assets/img/apple-touch-icon-152x152.png create mode 100644 lib/Rozier/src/Resources/app/assets/img/apple-touch-icon-180x180.png create mode 100644 lib/Rozier/src/Resources/app/assets/img/apple-touch-icon-57x57.png create mode 100644 lib/Rozier/src/Resources/app/assets/img/apple-touch-icon-60x60.png create mode 100644 lib/Rozier/src/Resources/app/assets/img/apple-touch-icon-72x72.png create mode 100644 lib/Rozier/src/Resources/app/assets/img/apple-touch-icon-76x76.png create mode 100644 lib/Rozier/src/Resources/app/assets/img/apple-touch-icon-precomposed.png create mode 100644 lib/Rozier/src/Resources/app/assets/img/apple-touch-icon.png create mode 100644 lib/Rozier/src/Resources/app/assets/img/browserconfig.xml create mode 100644 lib/Rozier/src/Resources/app/assets/img/default_login.jpg create mode 100644 lib/Rozier/src/Resources/app/assets/img/favicon-160x160.png create mode 100644 lib/Rozier/src/Resources/app/assets/img/favicon-16x16.png create mode 100644 lib/Rozier/src/Resources/app/assets/img/favicon-192x192.png create mode 100644 lib/Rozier/src/Resources/app/assets/img/favicon-32x32.png create mode 100644 lib/Rozier/src/Resources/app/assets/img/favicon-96x96.png create mode 100644 lib/Rozier/src/Resources/app/assets/img/favicon.ico create mode 100644 lib/Rozier/src/Resources/app/assets/img/jquery.minicolors.png create mode 100644 lib/Rozier/src/Resources/app/assets/img/jqueryui/ui-bg_diagonals-thick_18_b81900_40x40.png create mode 100644 lib/Rozier/src/Resources/app/assets/img/jqueryui/ui-bg_diagonals-thick_20_666666_40x40.png create mode 100644 lib/Rozier/src/Resources/app/assets/img/jqueryui/ui-bg_flat_10_000000_40x100.png create mode 100644 lib/Rozier/src/Resources/app/assets/img/jqueryui/ui-bg_glass_100_f6f6f6_1x400.png create mode 100644 lib/Rozier/src/Resources/app/assets/img/jqueryui/ui-bg_glass_100_fdf5ce_1x400.png create mode 100644 lib/Rozier/src/Resources/app/assets/img/jqueryui/ui-bg_glass_65_ffffff_1x400.png create mode 100644 lib/Rozier/src/Resources/app/assets/img/jqueryui/ui-bg_gloss-wave_35_f6a828_500x100.png create mode 100644 lib/Rozier/src/Resources/app/assets/img/jqueryui/ui-bg_highlight-soft_100_eeeeee_1x100.png create mode 100644 lib/Rozier/src/Resources/app/assets/img/jqueryui/ui-bg_highlight-soft_75_ffe45c_1x100.png create mode 100644 lib/Rozier/src/Resources/app/assets/img/jqueryui/ui-icons_222222_256x240.png create mode 100644 lib/Rozier/src/Resources/app/assets/img/jqueryui/ui-icons_228ef1_256x240.png create mode 100644 lib/Rozier/src/Resources/app/assets/img/jqueryui/ui-icons_ef8c08_256x240.png create mode 100644 lib/Rozier/src/Resources/app/assets/img/jqueryui/ui-icons_ffd27a_256x240.png create mode 100644 lib/Rozier/src/Resources/app/assets/img/jqueryui/ui-icons_ffffff_256x240.png create mode 100644 lib/Rozier/src/Resources/app/assets/img/map_marker.png create mode 100644 lib/Rozier/src/Resources/app/assets/img/marker.png create mode 100644 lib/Rozier/src/Resources/app/assets/img/marker@2x.png create mode 100644 lib/Rozier/src/Resources/app/assets/img/marker_shadow.png create mode 100644 lib/Rozier/src/Resources/app/assets/img/marker_shadow@2x.png create mode 100644 lib/Rozier/src/Resources/app/assets/img/matrix@2x.png create mode 100644 lib/Rozier/src/Resources/app/assets/img/mstile-144x144.png create mode 100644 lib/Rozier/src/Resources/app/assets/img/mstile-150x150.png create mode 100644 lib/Rozier/src/Resources/app/assets/img/mstile-310x150.png create mode 100644 lib/Rozier/src/Resources/app/assets/img/mstile-310x310.png create mode 100644 lib/Rozier/src/Resources/app/assets/img/mstile-70x70.png create mode 100644 lib/Rozier/src/Resources/app/assets/img/spritemap.png create mode 100644 lib/Rozier/src/Resources/app/assets/img/spritemap@2x.png create mode 100644 lib/Rozier/src/Resources/app/components/AjaxLink.vue create mode 100644 lib/Rozier/src/Resources/app/components/BlanchetteToolbar.vue create mode 100644 lib/Rozier/src/Resources/app/components/CodeMirror.vue create mode 100644 lib/Rozier/src/Resources/app/components/CustomFormPreviewItem.vue create mode 100644 lib/Rozier/src/Resources/app/components/DocumentPreviewListItem.vue create mode 100644 lib/Rozier/src/Resources/app/components/DrawerItem.vue create mode 100644 lib/Rozier/src/Resources/app/components/Dropzone.vue create mode 100644 lib/Rozier/src/Resources/app/components/ExplorerItemsInfos.vue create mode 100644 lib/Rozier/src/Resources/app/components/FilterExplorerButton.vue create mode 100644 lib/Rozier/src/Resources/app/components/FilterExplorerItem.vue create mode 100644 lib/Rozier/src/Resources/app/components/JoinPreviewItem.vue create mode 100644 lib/Rozier/src/Resources/app/components/LoadMoreButton.vue create mode 100644 lib/Rozier/src/Resources/app/components/NodePreviewItem.vue create mode 100644 lib/Rozier/src/Resources/app/components/NodeTypePreviewItem.vue create mode 100644 lib/Rozier/src/Resources/app/components/Overlay.vue create mode 100644 lib/Rozier/src/Resources/app/components/RzButton.vue create mode 100644 lib/Rozier/src/Resources/app/components/RzTextarea.vue create mode 100644 lib/Rozier/src/Resources/app/components/TagCreatorItem.vue create mode 100644 lib/Rozier/src/Resources/app/components/TagPreviewItem.vue create mode 100644 lib/Rozier/src/Resources/app/components/WarningModal.vue create mode 100644 lib/Rozier/src/Resources/app/components/attribute-values/AttributeValuePosition.js create mode 100644 lib/Rozier/src/Resources/app/components/bulk-edits/DocumentsBulk.js create mode 100644 lib/Rozier/src/Resources/app/components/bulk-edits/NodesBulk.js create mode 100644 lib/Rozier/src/Resources/app/components/bulk-edits/TagsBulk.js create mode 100644 lib/Rozier/src/Resources/app/components/custom-form-fields/CustomFormFieldEdit.js create mode 100644 lib/Rozier/src/Resources/app/components/custom-form-fields/CustomFormFieldsPosition.js create mode 100644 lib/Rozier/src/Resources/app/components/documents/DocumentUploader.js create mode 100644 lib/Rozier/src/Resources/app/components/documents/DocumentsList.js create mode 100644 lib/Rozier/src/Resources/app/components/import/Import.js create mode 100644 lib/Rozier/src/Resources/app/components/login/login.js create mode 100644 lib/Rozier/src/Resources/app/components/node-type-fields/NodeTypeFieldEdit.js create mode 100644 lib/Rozier/src/Resources/app/components/node-type-fields/NodeTypeFieldsPosition.js create mode 100644 lib/Rozier/src/Resources/app/components/node/NodeEditSource.js create mode 100644 lib/Rozier/src/Resources/app/components/panels/EntriesPanel.js create mode 100644 lib/Rozier/src/Resources/app/components/tabs/MainTreeTabs.js create mode 100644 lib/Rozier/src/Resources/app/components/tag/TagEdit.js create mode 100644 lib/Rozier/src/Resources/app/components/trees/NodeTreeContextActions.js create mode 100644 lib/Rozier/src/Resources/app/containers/BlanchetteEditorContainer.vue create mode 100644 lib/Rozier/src/Resources/app/containers/DocumentPreviewContainer.vue create mode 100644 lib/Rozier/src/Resources/app/containers/DrawerContainer.vue create mode 100644 lib/Rozier/src/Resources/app/containers/ExplorerContainer.vue create mode 100644 lib/Rozier/src/Resources/app/containers/FilterExplorerContainer.vue create mode 100644 lib/Rozier/src/Resources/app/containers/ModalContainer.vue create mode 100644 lib/Rozier/src/Resources/app/containers/NodeTypeFieldFormContainer.vue create mode 100644 lib/Rozier/src/Resources/app/containers/NodeTypesDrawerContainer.vue create mode 100644 lib/Rozier/src/Resources/app/containers/NodesSearchContainer.vue create mode 100644 lib/Rozier/src/Resources/app/containers/TagCreatorContainer.vue create mode 100644 lib/Rozier/src/Resources/app/containers/TagsEditorContainer.vue create mode 100644 lib/Rozier/src/Resources/app/directives/DynamicImg.js create mode 100644 lib/Rozier/src/Resources/app/factories/EntityAwareFactory.js create mode 100644 lib/Rozier/src/Resources/app/filters/centralTruncate.js create mode 100644 lib/Rozier/src/Resources/app/filters/index.js create mode 100644 lib/Rozier/src/Resources/app/filters/truncate.js create mode 100644 lib/Rozier/src/Resources/app/fonts/fa-brands-400.woff create mode 100644 lib/Rozier/src/Resources/app/fonts/fa-brands-400.woff2 create mode 100755 lib/Rozier/src/Resources/app/fonts/fontawesome-webfont.woff create mode 100755 lib/Rozier/src/Resources/app/fonts/fontawesome-webfont.woff2 create mode 100644 lib/Rozier/src/Resources/app/fonts/roadiz-sans/RoadizSans-Light.eot create mode 100644 lib/Rozier/src/Resources/app/fonts/roadiz-sans/RoadizSans-Light.woff create mode 100644 lib/Rozier/src/Resources/app/fonts/roadiz-sans/RoadizSans-Light.woff2 create mode 100644 lib/Rozier/src/Resources/app/fonts/roadiz-sans/RoadizSans-Medium.eot create mode 100644 lib/Rozier/src/Resources/app/fonts/roadiz-sans/RoadizSans-Medium.woff create mode 100644 lib/Rozier/src/Resources/app/fonts/roadiz-sans/RoadizSans-Medium.woff2 create mode 100644 lib/Rozier/src/Resources/app/fonts/roadiz-sans/RoadizSans-Mono.eot create mode 100644 lib/Rozier/src/Resources/app/fonts/roadiz-sans/RoadizSans-Mono.woff create mode 100644 lib/Rozier/src/Resources/app/fonts/roadiz-sans/RoadizSans-Mono.woff2 create mode 100644 lib/Rozier/src/Resources/app/fonts/roadiz-sans/RoadizSans-Regular.eot create mode 100644 lib/Rozier/src/Resources/app/fonts/roadiz-sans/RoadizSans-Regular.woff create mode 100644 lib/Rozier/src/Resources/app/fonts/roadiz-sans/RoadizSans-Regular.woff2 create mode 100644 lib/Rozier/src/Resources/app/fonts/roadiz-sans/roadiz-sans-LICENCE.md create mode 100644 lib/Rozier/src/Resources/app/fonts/rz-icons-LICENSE.md create mode 100644 lib/Rozier/src/Resources/app/fonts/rz-icons.sfd create mode 100644 lib/Rozier/src/Resources/app/fonts/rz-icons.woff create mode 100644 lib/Rozier/src/Resources/app/fonts/rz-icons.woff2 create mode 100644 lib/Rozier/src/Resources/app/less/actions_menu/actions_menu.less create mode 100644 lib/Rozier/src/Resources/app/less/alerts/alerts.less create mode 100644 lib/Rozier/src/Resources/app/less/animations/fade-in.less create mode 100644 lib/Rozier/src/Resources/app/less/animations/fade.less create mode 100644 lib/Rozier/src/Resources/app/less/animations/slide-left.less create mode 100644 lib/Rozier/src/Resources/app/less/attributes/attributes.less create mode 100644 lib/Rozier/src/Resources/app/less/autocomplete/autocomplete.less create mode 100644 lib/Rozier/src/Resources/app/less/autocomplete/tag-autocomplete.less create mode 100644 lib/Rozier/src/Resources/app/less/badges/badges.less create mode 100644 lib/Rozier/src/Resources/app/less/base/boilerplate.less create mode 100644 lib/Rozier/src/Resources/app/less/base/easing.less create mode 100755 lib/Rozier/src/Resources/app/less/base/elements.less create mode 100644 lib/Rozier/src/Resources/app/less/base/normalize.less create mode 100644 lib/Rozier/src/Resources/app/less/breadcrumb/breadcrumb.less create mode 100644 lib/Rozier/src/Resources/app/less/bulk/bulk.less create mode 100644 lib/Rozier/src/Resources/app/less/buttons/buttons.less create mode 100644 lib/Rozier/src/Resources/app/less/common.less create mode 100644 lib/Rozier/src/Resources/app/less/custom-forms-fields/custom-forms-fields.less create mode 100644 lib/Rozier/src/Resources/app/less/custom-forms-front.less create mode 100644 lib/Rozier/src/Resources/app/less/custom-forms/custom-forms.less create mode 100644 lib/Rozier/src/Resources/app/less/dashboard/latestSources.less create mode 100644 lib/Rozier/src/Resources/app/less/documents/documents.less create mode 100644 lib/Rozier/src/Resources/app/less/dropdown/dropdown.less create mode 100644 lib/Rozier/src/Resources/app/less/dropzone/dropzone.less create mode 100644 lib/Rozier/src/Resources/app/less/fonts/global.less create mode 100644 lib/Rozier/src/Resources/app/less/forms/collection_form.less create mode 100644 lib/Rozier/src/Resources/app/less/forms/custom_switch.less create mode 100644 lib/Rozier/src/Resources/app/less/forms/forms.less create mode 100644 lib/Rozier/src/Resources/app/less/forms/inline_form.less create mode 100644 lib/Rozier/src/Resources/app/less/forms/markdown_editor.less create mode 100644 lib/Rozier/src/Resources/app/less/grid/grid_layout.less create mode 100644 lib/Rozier/src/Resources/app/less/history/history.less create mode 100644 lib/Rozier/src/Resources/app/less/icons/icons.less create mode 100644 lib/Rozier/src/Resources/app/less/login/login.less create mode 100644 lib/Rozier/src/Resources/app/less/login/purge.less create mode 100644 lib/Rozier/src/Resources/app/less/navbars/common.less create mode 100644 lib/Rozier/src/Resources/app/less/navbars/filterbar.less create mode 100644 lib/Rozier/src/Resources/app/less/navbars/navigation.less create mode 100644 lib/Rozier/src/Resources/app/less/navbars/translationbar.less create mode 100644 lib/Rozier/src/Resources/app/less/node-type-fields/fields.less create mode 100644 lib/Rozier/src/Resources/app/less/node-types/node-types.less create mode 100644 lib/Rozier/src/Resources/app/less/nodes/edit.less create mode 100644 lib/Rozier/src/Resources/app/less/nodes/global.less create mode 100644 lib/Rozier/src/Resources/app/less/panels/entries_panel/admin_entries.less create mode 100644 lib/Rozier/src/Resources/app/less/panels/main_content_panel/main_content.less create mode 100644 lib/Rozier/src/Resources/app/less/panels/trees_panel/trees_panel.less create mode 100644 lib/Rozier/src/Resources/app/less/panels/user_panel/user_panel.less create mode 100644 lib/Rozier/src/Resources/app/less/responsive/less-1280.less create mode 100644 lib/Rozier/src/Resources/app/less/responsive/less-768.less create mode 100644 lib/Rozier/src/Resources/app/less/search/search.less create mode 100644 lib/Rozier/src/Resources/app/less/settings/global.less create mode 100644 lib/Rozier/src/Resources/app/less/style.less create mode 100644 lib/Rozier/src/Resources/app/less/tables/tables.less create mode 100644 lib/Rozier/src/Resources/app/less/tags/global.less create mode 100644 lib/Rozier/src/Resources/app/less/themes/import.less create mode 100644 lib/Rozier/src/Resources/app/less/themes/themes.less create mode 100644 lib/Rozier/src/Resources/app/less/translations/global.less create mode 100644 lib/Rozier/src/Resources/app/less/users/users.less create mode 100644 lib/Rozier/src/Resources/app/less/vars.less create mode 100644 lib/Rozier/src/Resources/app/less/vendor.less create mode 100755 lib/Rozier/src/Resources/app/less/vendor/bootstrap3/bootstrap-switch.css create mode 100755 lib/Rozier/src/Resources/app/less/vendor/bootstrap3/bootstrap-switch.min.css create mode 100644 lib/Rozier/src/Resources/app/less/vendor/codemirror.css create mode 100644 lib/Rozier/src/Resources/app/less/vendor/dropzone.less create mode 100755 lib/Rozier/src/Resources/app/less/vendor/jquery-ui.less create mode 100644 lib/Rozier/src/Resources/app/less/vendor/jquery.minicolors.css create mode 100755 lib/Rozier/src/Resources/app/less/vendor/uikit/addons/uikit.addons.css create mode 100755 lib/Rozier/src/Resources/app/less/vendor/uikit/addons/uikit.addons.min.css create mode 100755 lib/Rozier/src/Resources/app/less/vendor/uikit/addons/uikit.almost-flat.addons.css create mode 100755 lib/Rozier/src/Resources/app/less/vendor/uikit/addons/uikit.almost-flat.addons.min.css create mode 100755 lib/Rozier/src/Resources/app/less/vendor/uikit/addons/uikit.gradient.addons.css create mode 100755 lib/Rozier/src/Resources/app/less/vendor/uikit/addons/uikit.gradient.addons.min.css create mode 100644 lib/Rozier/src/Resources/app/less/vendor/uikit/uikit.almost-flat.css create mode 100644 lib/Rozier/src/Resources/app/less/vendor/uikit/uikit.almost-flat.min.css create mode 100644 lib/Rozier/src/Resources/app/less/vendor/uikit/uikit.css create mode 100644 lib/Rozier/src/Resources/app/less/vendor/uikit/uikit.gradient.css create mode 100644 lib/Rozier/src/Resources/app/less/vendor/uikit/uikit.gradient.min.css create mode 100644 lib/Rozier/src/Resources/app/less/vendor/uikit/uikit.min.css create mode 100644 lib/Rozier/src/Resources/app/less/widgets/children_nodes_widget.less create mode 100644 lib/Rozier/src/Resources/app/less/widgets/customform_widget.less create mode 100644 lib/Rozier/src/Resources/app/less/widgets/debugpanel.less create mode 100644 lib/Rozier/src/Resources/app/less/widgets/documents_widget.less create mode 100644 lib/Rozier/src/Resources/app/less/widgets/drawer_document_item.less create mode 100644 lib/Rozier/src/Resources/app/less/widgets/drawer_item.less create mode 100644 lib/Rozier/src/Resources/app/less/widgets/drawer_widget.less create mode 100644 lib/Rozier/src/Resources/app/less/widgets/explorer.less create mode 100644 lib/Rozier/src/Resources/app/less/widgets/filter_explorer.less create mode 100644 lib/Rozier/src/Resources/app/less/widgets/folder_tree.less create mode 100644 lib/Rozier/src/Resources/app/less/widgets/geotag_widget.less create mode 100644 lib/Rozier/src/Resources/app/less/widgets/nestable.less create mode 100644 lib/Rozier/src/Resources/app/less/widgets/node_tree.less create mode 100644 lib/Rozier/src/Resources/app/less/widgets/nodes_widget.less create mode 100644 lib/Rozier/src/Resources/app/less/widgets/sortable.less create mode 100644 lib/Rozier/src/Resources/app/less/widgets/spinner.less create mode 100644 lib/Rozier/src/Resources/app/less/widgets/stack_tree.less create mode 100644 lib/Rozier/src/Resources/app/less/widgets/tag_tree.less create mode 100644 lib/Rozier/src/Resources/app/main.js create mode 100644 lib/Rozier/src/Resources/app/scss/styles.scss create mode 100644 lib/Rozier/src/Resources/app/services/GeoCodingService.js create mode 100644 lib/Rozier/src/Resources/app/services/KeyboardEventService.js create mode 100644 lib/Rozier/src/Resources/app/services/LoginCheckService.js create mode 100644 lib/Rozier/src/Resources/app/simple.js create mode 100644 lib/Rozier/src/Resources/app/store/index.js create mode 100644 lib/Rozier/src/Resources/app/store/modules/BlanchetteEditorStoreModule.js create mode 100644 lib/Rozier/src/Resources/app/store/modules/DocumentPreviewStoreModule.js create mode 100644 lib/Rozier/src/Resources/app/store/modules/DrawersStoreModule.js create mode 100644 lib/Rozier/src/Resources/app/store/modules/ExplorerStoreModule.js create mode 100644 lib/Rozier/src/Resources/app/store/modules/FilterExplorerStoreModule.js create mode 100644 lib/Rozier/src/Resources/app/store/modules/NodesSourceSearchStoreModule.js create mode 100644 lib/Rozier/src/Resources/app/store/modules/TagsStoreModule.js create mode 100644 lib/Rozier/src/Resources/app/types/entityTypes.js create mode 100644 lib/Rozier/src/Resources/app/types/mutationTypes.js create mode 100644 lib/Rozier/src/Resources/app/utils/index.js create mode 100755 lib/Rozier/src/Resources/app/utils/plugins.js create mode 100644 lib/Rozier/src/Resources/app/vendor/jquery.collection.js create mode 100644 lib/Rozier/src/Resources/app/vendor/jquery.js create mode 100644 lib/Rozier/src/Resources/app/vendor/jquery.tag-editor.js create mode 100644 lib/Rozier/src/Resources/app/vendor/modernizr.custom.50380.js create mode 100644 lib/Rozier/src/Resources/app/vendor/vue.js create mode 100644 lib/Rozier/src/Resources/app/vendor/vuex.js create mode 100644 lib/Rozier/src/Resources/app/widgets/ChildrenNodesField.js create mode 100644 lib/Rozier/src/Resources/app/widgets/CssEditor.js create mode 100644 lib/Rozier/src/Resources/app/widgets/FolderAutocomplete.js create mode 100644 lib/Rozier/src/Resources/app/widgets/InputLengthWatcher.js create mode 100644 lib/Rozier/src/Resources/app/widgets/JsonEditor.js create mode 100644 lib/Rozier/src/Resources/app/widgets/LeafletGeotagField.js create mode 100644 lib/Rozier/src/Resources/app/widgets/MarkdownEditor.js create mode 100644 lib/Rozier/src/Resources/app/widgets/MultiLeafletGeotagField.js create mode 100644 lib/Rozier/src/Resources/app/widgets/NodeStatuses.js create mode 100644 lib/Rozier/src/Resources/app/widgets/NodeTree.js create mode 100644 lib/Rozier/src/Resources/app/widgets/SaveButtons.js create mode 100644 lib/Rozier/src/Resources/app/widgets/SettingsSaveButtons.js create mode 100644 lib/Rozier/src/Resources/app/widgets/StackNodeTree.js create mode 100644 lib/Rozier/src/Resources/app/widgets/TagAutocomplete.js create mode 100644 lib/Rozier/src/Resources/app/widgets/YamlEditor.js create mode 100644 lib/Rozier/src/Resources/routes.yml create mode 100644 lib/Rozier/src/Resources/translations/helps.ar.xlf create mode 100644 lib/Rozier/src/Resources/translations/helps.de.xlf create mode 100644 lib/Rozier/src/Resources/translations/helps.en.xlf create mode 100644 lib/Rozier/src/Resources/translations/helps.es.xlf create mode 100644 lib/Rozier/src/Resources/translations/helps.fr.xlf create mode 100644 lib/Rozier/src/Resources/translations/helps.id.xlf create mode 100644 lib/Rozier/src/Resources/translations/helps.it.xlf create mode 100644 lib/Rozier/src/Resources/translations/helps.ru.xlf create mode 100644 lib/Rozier/src/Resources/translations/helps.sr.xlf create mode 100644 lib/Rozier/src/Resources/translations/helps.tr.xlf create mode 100644 lib/Rozier/src/Resources/translations/helps.uk.xlf create mode 100644 lib/Rozier/src/Resources/translations/helps.xlf create mode 100644 lib/Rozier/src/Resources/translations/helps.zh.xlf create mode 100644 lib/Rozier/src/Resources/translations/messages.ar.xlf create mode 100644 lib/Rozier/src/Resources/translations/messages.de.xlf create mode 100644 lib/Rozier/src/Resources/translations/messages.en.xlf create mode 100644 lib/Rozier/src/Resources/translations/messages.es.xlf create mode 100644 lib/Rozier/src/Resources/translations/messages.fr.xlf create mode 100644 lib/Rozier/src/Resources/translations/messages.id.xlf create mode 100644 lib/Rozier/src/Resources/translations/messages.it.xlf create mode 100644 lib/Rozier/src/Resources/translations/messages.ru.xlf create mode 100644 lib/Rozier/src/Resources/translations/messages.sr.xlf create mode 100644 lib/Rozier/src/Resources/translations/messages.tr.xlf create mode 100644 lib/Rozier/src/Resources/translations/messages.uk.xlf create mode 100644 lib/Rozier/src/Resources/translations/messages.xlf create mode 100644 lib/Rozier/src/Resources/translations/messages.zh.xlf create mode 100644 lib/Rozier/src/Resources/translations/settings.ar.xlf create mode 100644 lib/Rozier/src/Resources/translations/settings.de.xlf create mode 100644 lib/Rozier/src/Resources/translations/settings.en.xlf create mode 100644 lib/Rozier/src/Resources/translations/settings.es.xlf create mode 100644 lib/Rozier/src/Resources/translations/settings.fr.xlf create mode 100644 lib/Rozier/src/Resources/translations/settings.id.xlf create mode 100644 lib/Rozier/src/Resources/translations/settings.it.xlf create mode 100644 lib/Rozier/src/Resources/translations/settings.ru.xlf create mode 100644 lib/Rozier/src/Resources/translations/settings.sr.xlf create mode 100644 lib/Rozier/src/Resources/translations/settings.tr.xlf create mode 100644 lib/Rozier/src/Resources/translations/settings.uk.xlf create mode 100644 lib/Rozier/src/Resources/translations/settings.xlf create mode 100644 lib/Rozier/src/Resources/translations/settings.zh.xlf create mode 100644 lib/Rozier/src/Resources/views/admin/base.html.twig create mode 100644 lib/Rozier/src/Resources/views/admin/blocks/adminImage.html.twig create mode 100644 lib/Rozier/src/Resources/views/admin/blocks/loginImage.html.twig create mode 100644 lib/Rozier/src/Resources/views/admin/meta-icon.html.twig create mode 100644 lib/Rozier/src/Resources/views/admin/webhooks/actionsMenu.html.twig create mode 100644 lib/Rozier/src/Resources/views/admin/webhooks/add.html.twig create mode 100644 lib/Rozier/src/Resources/views/admin/webhooks/delete.html.twig create mode 100644 lib/Rozier/src/Resources/views/admin/webhooks/edit.html.twig create mode 100644 lib/Rozier/src/Resources/views/admin/webhooks/list.html.twig create mode 100644 lib/Rozier/src/Resources/views/admin/webhooks/trigger.html.twig create mode 100644 lib/Rozier/src/Resources/views/ajaxBase.html.twig create mode 100644 lib/Rozier/src/Resources/views/attributes/actionsMenu.html.twig create mode 100644 lib/Rozier/src/Resources/views/attributes/add.html.twig create mode 100644 lib/Rozier/src/Resources/views/attributes/delete.html.twig create mode 100644 lib/Rozier/src/Resources/views/attributes/edit.html.twig create mode 100644 lib/Rozier/src/Resources/views/attributes/groups/actionsMenu.html.twig create mode 100644 lib/Rozier/src/Resources/views/attributes/groups/add.html.twig create mode 100644 lib/Rozier/src/Resources/views/attributes/groups/delete.html.twig create mode 100644 lib/Rozier/src/Resources/views/attributes/groups/edit.html.twig create mode 100644 lib/Rozier/src/Resources/views/attributes/groups/list.html.twig create mode 100644 lib/Rozier/src/Resources/views/attributes/import.html.twig create mode 100644 lib/Rozier/src/Resources/views/attributes/list.html.twig create mode 100644 lib/Rozier/src/Resources/views/base.html.twig create mode 100644 lib/Rozier/src/Resources/views/cache/deleteAssets.html.twig create mode 100644 lib/Rozier/src/Resources/views/cache/deleteDoctrine.html.twig create mode 100644 lib/Rozier/src/Resources/views/css/mainColor.css.twig create mode 100644 lib/Rozier/src/Resources/views/custom-form-answers/delete.html.twig create mode 100644 lib/Rozier/src/Resources/views/custom-form-answers/list.html.twig create mode 100644 lib/Rozier/src/Resources/views/custom-form-field-attributes/list.html.twig create mode 100644 lib/Rozier/src/Resources/views/custom-form-fields/actionsMenu.html.twig create mode 100644 lib/Rozier/src/Resources/views/custom-form-fields/add.html.twig create mode 100644 lib/Rozier/src/Resources/views/custom-form-fields/ajaxEditBase.html.twig create mode 100644 lib/Rozier/src/Resources/views/custom-form-fields/delete.html.twig create mode 100644 lib/Rozier/src/Resources/views/custom-form-fields/edit.html.twig create mode 100644 lib/Rozier/src/Resources/views/custom-form-fields/editBase.html.twig create mode 100644 lib/Rozier/src/Resources/views/custom-form-fields/list.html.twig create mode 100644 lib/Rozier/src/Resources/views/custom-forms/actionsMenu.html.twig create mode 100644 lib/Rozier/src/Resources/views/custom-forms/add.html.twig create mode 100644 lib/Rozier/src/Resources/views/custom-forms/delete.html.twig create mode 100644 lib/Rozier/src/Resources/views/custom-forms/edit.html.twig create mode 100644 lib/Rozier/src/Resources/views/custom-forms/list.html.twig create mode 100644 lib/Rozier/src/Resources/views/custom-forms/navBar.html.twig create mode 100644 lib/Rozier/src/Resources/views/dashboard/index.html.twig create mode 100644 lib/Rozier/src/Resources/views/dashboard/navBar.html.twig create mode 100644 lib/Rozier/src/Resources/views/document-translations/delete.html.twig create mode 100644 lib/Rozier/src/Resources/views/document-translations/edit.html.twig create mode 100644 lib/Rozier/src/Resources/views/document-translations/translationBar.html.twig create mode 100644 lib/Rozier/src/Resources/views/documents/actionsMenu.html.twig create mode 100644 lib/Rozier/src/Resources/views/documents/adjust.html.twig create mode 100644 lib/Rozier/src/Resources/views/documents/backLink.html.twig create mode 100644 lib/Rozier/src/Resources/views/documents/bulkDelete.html.twig create mode 100644 lib/Rozier/src/Resources/views/documents/bulkDownload.html.twig create mode 100644 lib/Rozier/src/Resources/views/documents/delete.html.twig create mode 100644 lib/Rozier/src/Resources/views/documents/edit.html.twig create mode 100644 lib/Rozier/src/Resources/views/documents/embed.html.twig create mode 100644 lib/Rozier/src/Resources/views/documents/filtersBar.html.twig create mode 100644 lib/Rozier/src/Resources/views/documents/itemPerPage.html.twig create mode 100644 lib/Rozier/src/Resources/views/documents/navBar.html.twig create mode 100644 lib/Rozier/src/Resources/views/documents/singleDocumentThumbnail.html.twig create mode 100644 lib/Rozier/src/Resources/views/documents/upload.html.twig create mode 100644 lib/Rozier/src/Resources/views/documents/usage.html.twig create mode 100644 lib/Rozier/src/Resources/views/folders/actionsMenu.html.twig create mode 100644 lib/Rozier/src/Resources/views/folders/add.html.twig create mode 100644 lib/Rozier/src/Resources/views/folders/delete.html.twig create mode 100644 lib/Rozier/src/Resources/views/folders/edit.html.twig create mode 100644 lib/Rozier/src/Resources/views/folders/list.html.twig create mode 100644 lib/Rozier/src/Resources/views/folders/navBar.html.twig create mode 100644 lib/Rozier/src/Resources/views/folders/translationBar.html.twig create mode 100644 lib/Rozier/src/Resources/views/forms.html.twig create mode 100644 lib/Rozier/src/Resources/views/groups/actionsMenu.html.twig create mode 100644 lib/Rozier/src/Resources/views/groups/add.html.twig create mode 100644 lib/Rozier/src/Resources/views/groups/delete.html.twig create mode 100644 lib/Rozier/src/Resources/views/groups/edit.html.twig create mode 100644 lib/Rozier/src/Resources/views/groups/import.html.twig create mode 100644 lib/Rozier/src/Resources/views/groups/list.html.twig create mode 100644 lib/Rozier/src/Resources/views/groups/navBar.html.twig create mode 100644 lib/Rozier/src/Resources/views/groups/removeRole.html.twig create mode 100644 lib/Rozier/src/Resources/views/groups/removeUser.html.twig create mode 100644 lib/Rozier/src/Resources/views/groups/roles.html.twig create mode 100644 lib/Rozier/src/Resources/views/groups/users.html.twig create mode 100644 lib/Rozier/src/Resources/views/history/list.html.twig create mode 100644 lib/Rozier/src/Resources/views/horizontalForms.html.twig create mode 100644 lib/Rozier/src/Resources/views/includes/column_ordering.html.twig create mode 100644 lib/Rozier/src/Resources/views/includes/messages.html.twig create mode 100644 lib/Rozier/src/Resources/views/layout.html.twig create mode 100644 lib/Rozier/src/Resources/views/login/base.html.twig create mode 100644 lib/Rozier/src/Resources/views/login/check.html.twig create mode 100644 lib/Rozier/src/Resources/views/login/request.html.twig create mode 100644 lib/Rozier/src/Resources/views/login/requestConfirm.html.twig create mode 100644 lib/Rozier/src/Resources/views/login/reset.html.twig create mode 100644 lib/Rozier/src/Resources/views/login/resetConfirm.html.twig create mode 100644 lib/Rozier/src/Resources/views/modules/history-item.html.twig create mode 100644 lib/Rozier/src/Resources/views/node-type-fields/actionsMenu.html.twig create mode 100644 lib/Rozier/src/Resources/views/node-type-fields/add.html.twig create mode 100644 lib/Rozier/src/Resources/views/node-type-fields/ajaxEditBase.html.twig create mode 100644 lib/Rozier/src/Resources/views/node-type-fields/delete.html.twig create mode 100644 lib/Rozier/src/Resources/views/node-type-fields/edit.html.twig create mode 100644 lib/Rozier/src/Resources/views/node-type-fields/editBase.html.twig create mode 100644 lib/Rozier/src/Resources/views/node-type-fields/list.html.twig create mode 100644 lib/Rozier/src/Resources/views/node-types/actionsMenu.html.twig create mode 100644 lib/Rozier/src/Resources/views/node-types/add.html.twig create mode 100644 lib/Rozier/src/Resources/views/node-types/delete.html.twig create mode 100644 lib/Rozier/src/Resources/views/node-types/edit.html.twig create mode 100644 lib/Rozier/src/Resources/views/node-types/import.html.twig create mode 100644 lib/Rozier/src/Resources/views/node-types/list.html.twig create mode 100644 lib/Rozier/src/Resources/views/node-types/navBar.html.twig create mode 100644 lib/Rozier/src/Resources/views/nodes/actionsMenu.html.twig create mode 100644 lib/Rozier/src/Resources/views/nodes/add.html.twig create mode 100644 lib/Rozier/src/Resources/views/nodes/attributes/delete.html.twig create mode 100644 lib/Rozier/src/Resources/views/nodes/attributes/edit.html.twig create mode 100644 lib/Rozier/src/Resources/views/nodes/attributes/reset.html.twig create mode 100644 lib/Rozier/src/Resources/views/nodes/attributes/translationBar.html.twig create mode 100644 lib/Rozier/src/Resources/views/nodes/breadcrumb.html.twig create mode 100644 lib/Rozier/src/Resources/views/nodes/bulkDelete.html.twig create mode 100644 lib/Rozier/src/Resources/views/nodes/bulkStatus.html.twig create mode 100644 lib/Rozier/src/Resources/views/nodes/delete.html.twig create mode 100644 lib/Rozier/src/Resources/views/nodes/deleteSource.html.twig create mode 100644 lib/Rozier/src/Resources/views/nodes/edit.html.twig create mode 100644 lib/Rozier/src/Resources/views/nodes/editAliases.html.twig create mode 100644 lib/Rozier/src/Resources/views/nodes/editSource.html.twig create mode 100644 lib/Rozier/src/Resources/views/nodes/editTags.html.twig create mode 100644 lib/Rozier/src/Resources/views/nodes/emptyTrash.html.twig create mode 100644 lib/Rozier/src/Resources/views/nodes/filtersBar.html.twig create mode 100644 lib/Rozier/src/Resources/views/nodes/history.html.twig create mode 100644 lib/Rozier/src/Resources/views/nodes/list.html.twig create mode 100644 lib/Rozier/src/Resources/views/nodes/navBack.html.twig create mode 100644 lib/Rozier/src/Resources/views/nodes/navBar.html.twig create mode 100644 lib/Rozier/src/Resources/views/nodes/nodeTypeCircle.html.twig create mode 100644 lib/Rozier/src/Resources/views/nodes/publishAll.html.twig create mode 100644 lib/Rozier/src/Resources/views/nodes/removeTag.html.twig create mode 100644 lib/Rozier/src/Resources/views/nodes/translate.html.twig create mode 100644 lib/Rozier/src/Resources/views/nodes/translationBar.html.twig create mode 100644 lib/Rozier/src/Resources/views/nodes/translationSEOBar.html.twig create mode 100644 lib/Rozier/src/Resources/views/nodes/transtype.html.twig create mode 100644 lib/Rozier/src/Resources/views/nodes/tree.html.twig create mode 100644 lib/Rozier/src/Resources/views/nodes/undelete.html.twig create mode 100644 lib/Rozier/src/Resources/views/nodes/widgets/node-header.html.twig create mode 100644 lib/Rozier/src/Resources/views/nodes/widgets/node-row.html.twig create mode 100644 lib/Rozier/src/Resources/views/panels/admin_menu.html.twig create mode 100644 lib/Rozier/src/Resources/views/panels/tree_panel.html.twig create mode 100644 lib/Rozier/src/Resources/views/panels/user_panel.html.twig create mode 100644 lib/Rozier/src/Resources/views/partials/css-inject-src.html.twig create mode 100644 lib/Rozier/src/Resources/views/partials/css-inject.html.twig create mode 100644 lib/Rozier/src/Resources/views/partials/js-inject-src.html.twig create mode 100644 lib/Rozier/src/Resources/views/partials/js-inject.html.twig create mode 100644 lib/Rozier/src/Resources/views/partials/simple-js-inject-src.html.twig create mode 100644 lib/Rozier/src/Resources/views/partials/simple-js-inject.html.twig create mode 100644 lib/Rozier/src/Resources/views/redirections/actionsMenu.html.twig create mode 100644 lib/Rozier/src/Resources/views/redirections/add.html.twig create mode 100644 lib/Rozier/src/Resources/views/redirections/delete.html.twig create mode 100644 lib/Rozier/src/Resources/views/redirections/edit.html.twig create mode 100644 lib/Rozier/src/Resources/views/redirections/editRow.html.twig create mode 100644 lib/Rozier/src/Resources/views/redirections/list.html.twig create mode 100644 lib/Rozier/src/Resources/views/roles/actionsMenu.html.twig create mode 100644 lib/Rozier/src/Resources/views/roles/add.html.twig create mode 100644 lib/Rozier/src/Resources/views/roles/delete.html.twig create mode 100644 lib/Rozier/src/Resources/views/roles/edit.html.twig create mode 100644 lib/Rozier/src/Resources/views/roles/import.html.twig create mode 100644 lib/Rozier/src/Resources/views/roles/list.html.twig create mode 100644 lib/Rozier/src/Resources/views/search/list.html.twig create mode 100644 lib/Rozier/src/Resources/views/settingGroups/actionsMenu.html.twig create mode 100644 lib/Rozier/src/Resources/views/settingGroups/add.html.twig create mode 100644 lib/Rozier/src/Resources/views/settingGroups/delete.html.twig create mode 100644 lib/Rozier/src/Resources/views/settingGroups/edit.html.twig create mode 100644 lib/Rozier/src/Resources/views/settingGroups/list.html.twig create mode 100644 lib/Rozier/src/Resources/views/settings/actionsMenu.html.twig create mode 100644 lib/Rozier/src/Resources/views/settings/add.html.twig create mode 100644 lib/Rozier/src/Resources/views/settings/delete.html.twig create mode 100644 lib/Rozier/src/Resources/views/settings/edit.html.twig create mode 100644 lib/Rozier/src/Resources/views/settings/import.html.twig create mode 100644 lib/Rozier/src/Resources/views/settings/list.html.twig create mode 100644 lib/Rozier/src/Resources/views/simple.html.twig create mode 100644 lib/Rozier/src/Resources/views/tags/actionsMenu.html.twig create mode 100644 lib/Rozier/src/Resources/views/tags/add-multiple.html.twig create mode 100644 lib/Rozier/src/Resources/views/tags/add.html.twig create mode 100644 lib/Rozier/src/Resources/views/tags/bulkDelete.html.twig create mode 100644 lib/Rozier/src/Resources/views/tags/delete.html.twig create mode 100644 lib/Rozier/src/Resources/views/tags/edit.html.twig create mode 100644 lib/Rozier/src/Resources/views/tags/filtersBar.html.twig create mode 100644 lib/Rozier/src/Resources/views/tags/list.html.twig create mode 100644 lib/Rozier/src/Resources/views/tags/navBack.html.twig create mode 100644 lib/Rozier/src/Resources/views/tags/navBar.html.twig create mode 100644 lib/Rozier/src/Resources/views/tags/nodes.html.twig create mode 100644 lib/Rozier/src/Resources/views/tags/settings.html.twig create mode 100644 lib/Rozier/src/Resources/views/tags/translationBar.html.twig create mode 100644 lib/Rozier/src/Resources/views/tags/tree.html.twig create mode 100644 lib/Rozier/src/Resources/views/translations/actionsMenu.html.twig create mode 100644 lib/Rozier/src/Resources/views/translations/add.html.twig create mode 100644 lib/Rozier/src/Resources/views/translations/delete.html.twig create mode 100644 lib/Rozier/src/Resources/views/translations/edit.html.twig create mode 100644 lib/Rozier/src/Resources/views/translations/list.html.twig create mode 100644 lib/Rozier/src/Resources/views/url-aliases/editRow.html.twig create mode 100644 lib/Rozier/src/Resources/views/users/actionsMenu.html.twig create mode 100644 lib/Rozier/src/Resources/views/users/add.html.twig create mode 100644 lib/Rozier/src/Resources/views/users/delete.html.twig create mode 100644 lib/Rozier/src/Resources/views/users/edit.html.twig create mode 100644 lib/Rozier/src/Resources/views/users/editDetails.html.twig create mode 100644 lib/Rozier/src/Resources/views/users/groups.html.twig create mode 100644 lib/Rozier/src/Resources/views/users/navBar.html.twig create mode 100644 lib/Rozier/src/Resources/views/users/removeGroup.html.twig create mode 100644 lib/Rozier/src/Resources/views/users/removeRole.html.twig create mode 100644 lib/Rozier/src/Resources/views/users/roles.html.twig create mode 100644 lib/Rozier/src/Resources/views/users/security.html.twig create mode 100644 lib/Rozier/src/Resources/views/widgets/backlink.html.twig create mode 100644 lib/Rozier/src/Resources/views/widgets/countFiltersBar.html.twig create mode 100644 lib/Rozier/src/Resources/views/widgets/customFormSmallThumbnail.html.twig create mode 100644 lib/Rozier/src/Resources/views/widgets/documentSmallThumbnail.html.twig create mode 100644 lib/Rozier/src/Resources/views/widgets/drawer.html.twig create mode 100644 lib/Rozier/src/Resources/views/widgets/filtersBar.html.twig create mode 100644 lib/Rozier/src/Resources/views/widgets/folderTree/folderTree.html.twig create mode 100644 lib/Rozier/src/Resources/views/widgets/folderTree/singleFolder.html.twig create mode 100644 lib/Rozier/src/Resources/views/widgets/itemPerPage.html.twig create mode 100644 lib/Rozier/src/Resources/views/widgets/nodeSmallThumbnail.html.twig create mode 100644 lib/Rozier/src/Resources/views/widgets/nodeTree/contextualMenu.html.twig create mode 100644 lib/Rozier/src/Resources/views/widgets/nodeTree/nodeStackTree.html.twig create mode 100644 lib/Rozier/src/Resources/views/widgets/nodeTree/nodeTree.html.twig create mode 100644 lib/Rozier/src/Resources/views/widgets/nodeTree/singleNode.html.twig create mode 100644 lib/Rozier/src/Resources/views/widgets/nodesSourcesSearch.html.twig create mode 100644 lib/Rozier/src/Resources/views/widgets/tagTree/singleTag.html.twig create mode 100644 lib/Rozier/src/Resources/views/widgets/tagTree/tagTree.html.twig create mode 100644 lib/Rozier/src/Resources/views/widgets/versionItem.html.twig create mode 100755 lib/Rozier/src/Resources/webpack/build/base.js create mode 100755 lib/Rozier/src/Resources/webpack/build/environments.js create mode 100755 lib/Rozier/src/Resources/webpack/build/index.js create mode 100755 lib/Rozier/src/Resources/webpack/config/base.js create mode 100755 lib/Rozier/src/Resources/webpack/config/environments.js create mode 100755 lib/Rozier/src/Resources/webpack/config/index.js create mode 100644 lib/Rozier/src/RozierApp.php create mode 100644 lib/Rozier/src/RozierServiceRegistry.php create mode 100644 lib/Rozier/src/Serialization/DocumentThumbnailSerializeSubscriber.php create mode 100644 lib/Rozier/src/Traits/NodesTrait.php create mode 100644 lib/Rozier/src/Traits/VersionedControllerTrait.php create mode 100644 lib/Rozier/src/Utils/SessionListFilters.php create mode 100644 lib/Rozier/src/Widgets/AbstractWidget.php create mode 100644 lib/Rozier/src/Widgets/FolderTreeWidget.php create mode 100644 lib/Rozier/src/Widgets/NodeTreeWidget.php create mode 100644 lib/Rozier/src/Widgets/TagTreeWidget.php create mode 100644 lib/Rozier/src/Widgets/TreeWidgetFactory.php create mode 100644 lib/Rozier/src/bower.json create mode 100644 lib/Rozier/src/package.json create mode 100644 lib/Rozier/src/static/assets/fonts/FontAwesome.otf create mode 100644 lib/Rozier/src/static/assets/fonts/fontawesome-webfont.eot create mode 100644 lib/Rozier/src/static/assets/fonts/fontawesome-webfont.ttf create mode 100644 lib/Rozier/src/static/assets/fonts/fontawesome-webfont.woff create mode 100644 lib/Rozier/src/static/assets/fonts/fontawesome-webfont.woff2 create mode 100644 lib/Rozier/src/static/assets/fonts/rz-icons-LICENSE.md create mode 100644 lib/Rozier/src/static/assets/fonts/rz-icons.eot create mode 100644 lib/Rozier/src/static/assets/fonts/rz-icons.svg create mode 100644 lib/Rozier/src/static/assets/fonts/rz-icons.ttf create mode 100644 lib/Rozier/src/static/assets/fonts/rz-icons.woff create mode 100644 lib/Rozier/src/static/assets/fonts/rz-icons.woff2 create mode 100644 lib/Rozier/src/static/assets/img/apple-touch-icon-114x114.png create mode 100644 lib/Rozier/src/static/assets/img/apple-touch-icon-120x120.png create mode 100644 lib/Rozier/src/static/assets/img/apple-touch-icon-144x144.png create mode 100644 lib/Rozier/src/static/assets/img/apple-touch-icon-152x152.png create mode 100644 lib/Rozier/src/static/assets/img/apple-touch-icon-180x180.png create mode 100644 lib/Rozier/src/static/assets/img/apple-touch-icon-57x57.png create mode 100644 lib/Rozier/src/static/assets/img/apple-touch-icon-60x60.png create mode 100644 lib/Rozier/src/static/assets/img/apple-touch-icon-72x72.png create mode 100644 lib/Rozier/src/static/assets/img/apple-touch-icon-76x76.png create mode 100644 lib/Rozier/src/static/assets/img/apple-touch-icon-precomposed.png create mode 100644 lib/Rozier/src/static/assets/img/apple-touch-icon.png create mode 100644 lib/Rozier/src/static/assets/img/browserconfig.xml create mode 100644 lib/Rozier/src/static/assets/img/default_login.jpg create mode 100644 lib/Rozier/src/static/assets/img/favicon-160x160.png create mode 100644 lib/Rozier/src/static/assets/img/favicon-16x16.png create mode 100644 lib/Rozier/src/static/assets/img/favicon-192x192.png create mode 100644 lib/Rozier/src/static/assets/img/favicon-32x32.png create mode 100644 lib/Rozier/src/static/assets/img/favicon-96x96.png create mode 100644 lib/Rozier/src/static/assets/img/favicon.ico create mode 100644 lib/Rozier/src/static/assets/img/jquery.minicolors.png create mode 100644 lib/Rozier/src/static/assets/img/jqueryui/ui-bg_diagonals-thick_18_b81900_40x40.png create mode 100644 lib/Rozier/src/static/assets/img/jqueryui/ui-bg_diagonals-thick_20_666666_40x40.png create mode 100644 lib/Rozier/src/static/assets/img/jqueryui/ui-bg_flat_10_000000_40x100.png create mode 100644 lib/Rozier/src/static/assets/img/jqueryui/ui-bg_glass_100_f6f6f6_1x400.png create mode 100644 lib/Rozier/src/static/assets/img/jqueryui/ui-bg_glass_100_fdf5ce_1x400.png create mode 100644 lib/Rozier/src/static/assets/img/jqueryui/ui-bg_glass_65_ffffff_1x400.png create mode 100644 lib/Rozier/src/static/assets/img/jqueryui/ui-bg_gloss-wave_35_f6a828_500x100.png create mode 100644 lib/Rozier/src/static/assets/img/jqueryui/ui-bg_highlight-soft_100_eeeeee_1x100.png create mode 100644 lib/Rozier/src/static/assets/img/jqueryui/ui-bg_highlight-soft_75_ffe45c_1x100.png create mode 100644 lib/Rozier/src/static/assets/img/jqueryui/ui-icons_222222_256x240.png create mode 100644 lib/Rozier/src/static/assets/img/jqueryui/ui-icons_228ef1_256x240.png create mode 100644 lib/Rozier/src/static/assets/img/jqueryui/ui-icons_ef8c08_256x240.png create mode 100644 lib/Rozier/src/static/assets/img/jqueryui/ui-icons_ffd27a_256x240.png create mode 100644 lib/Rozier/src/static/assets/img/jqueryui/ui-icons_ffffff_256x240.png create mode 100644 lib/Rozier/src/static/assets/img/map_marker.png create mode 100644 lib/Rozier/src/static/assets/img/marker.png create mode 100644 lib/Rozier/src/static/assets/img/marker@2x.png create mode 100644 lib/Rozier/src/static/assets/img/marker_shadow.png create mode 100644 lib/Rozier/src/static/assets/img/marker_shadow@2x.png create mode 100644 lib/Rozier/src/static/assets/img/matrix@2x.png create mode 100644 lib/Rozier/src/static/assets/img/mstile-144x144.png create mode 100644 lib/Rozier/src/static/assets/img/mstile-150x150.png create mode 100644 lib/Rozier/src/static/assets/img/mstile-310x150.png create mode 100644 lib/Rozier/src/static/assets/img/mstile-310x310.png create mode 100644 lib/Rozier/src/static/assets/img/mstile-70x70.png create mode 100644 lib/Rozier/src/static/assets/img/spritemap.png create mode 100644 lib/Rozier/src/static/assets/img/spritemap@2x.png create mode 100644 lib/Rozier/src/static/assets/logo.png create mode 100644 lib/Rozier/src/static/css/app.3d6bf1608c5a8f3962cd.css create mode 100644 lib/Rozier/src/static/css/app.3d6bf1608c5a8f3962cd.css.map create mode 100644 lib/Rozier/src/static/css/vendor.aaeedaa02e00b12e41cf.css create mode 100644 lib/Rozier/src/static/css/vendor.aaeedaa02e00b12e41cf.css.map create mode 100644 lib/Rozier/src/static/fonts/RoadizSans-Light.3cb2dde3ccc587cd4b9015acd888d682.woff2 create mode 100644 lib/Rozier/src/static/fonts/RoadizSans-Light.4b78aae656bca1227c451a681682a81d.woff create mode 100644 lib/Rozier/src/static/fonts/RoadizSans-Medium.1f2274d646ff39afd4b05eb40736e6d2.woff2 create mode 100644 lib/Rozier/src/static/fonts/RoadizSans-Medium.d099b2b90aec499cb3500c79ccd40962.woff create mode 100644 lib/Rozier/src/static/fonts/RoadizSans-Mono.04f18efe0897251ef8b8fd05387da836.woff2 create mode 100644 lib/Rozier/src/static/fonts/RoadizSans-Mono.c4fe4f0d09dd98e8e016ebf66f6af85e.woff create mode 100644 lib/Rozier/src/static/fonts/RoadizSans-Regular.4ad219c9931fb2a89e21a9a88f7c1ff7.woff create mode 100644 lib/Rozier/src/static/fonts/RoadizSans-Regular.78075c1857b3a12055a7d6ef730e66d9.woff2 create mode 100644 lib/Rozier/src/static/fonts/fa-brands-400.2ef8ba3410dcc71578a880e7064acd7a.woff create mode 100644 lib/Rozier/src/static/fonts/fa-brands-400.5e2f92123d241cabecf0b289b9b08d4a.woff2 create mode 100644 lib/Rozier/src/static/fonts/fontawesome-webfont.7efdddc8b19e3b4e3837c28ad9397462.woff2 create mode 100644 lib/Rozier/src/static/fonts/fontawesome-webfont.af7ae505a9eed503f8b8e6982036873e.woff2 create mode 100644 lib/Rozier/src/static/fonts/fontawesome-webfont.b06871f281fee6b241d60582ae9369b9.ttf create mode 100644 lib/Rozier/src/static/fonts/fontawesome-webfont.fdf491ce5ff5b2da02708cd0e9864719.woff create mode 100644 lib/Rozier/src/static/fonts/fontawesome-webfont.fee66e712a8a08eef5805a46892932ad.woff create mode 100644 lib/Rozier/src/static/fonts/rz-icons.68ee2ee734d5585c5aa2b9865de8f93a.woff2 create mode 100644 lib/Rozier/src/static/fonts/rz-icons.73621ebcb44f3992df7facc158976c0b.woff create mode 100644 lib/Rozier/src/static/img/jquery.minicolors.png create mode 100644 lib/Rozier/src/static/img/spritemap.png create mode 100644 lib/Rozier/src/static/img/spritemap@2x.png create mode 100644 lib/Rozier/src/static/js/app.a8318c6ba29b9defcd69.js create mode 100644 lib/Rozier/src/static/js/simple.a8318c6ba29b9defcd69.js create mode 100644 lib/Rozier/src/static/js/vendor.a8318c6ba29b9defcd69.js create mode 100644 lib/Rozier/src/static/vendor/codemirror-rulers.js create mode 100644 lib/Rozier/src/static/vendor/jquery-2.2.4.min.js create mode 100644 lib/Rozier/src/static/vendor/jquery-ui.custom.js create mode 100644 lib/Rozier/src/static/vendor/jquery.collection.js create mode 100644 lib/Rozier/src/static/vendor/jquery.js create mode 100644 lib/Rozier/src/static/vendor/jquery.min.js create mode 100644 lib/Rozier/src/static/vendor/jquery.tag-editor.js create mode 100644 lib/Rozier/src/static/vendor/modernizr.custom.50380.js create mode 100644 lib/Rozier/src/static/vendor/vue.js create mode 100644 lib/Rozier/src/static/vendor/vue.min.js create mode 100644 lib/Rozier/src/static/vendor/vuex.js create mode 100644 lib/Rozier/src/static/vendor/vuex.min.js create mode 100644 lib/Rozier/src/webpack.config.babel.js create mode 100644 lib/Rozier/src/yarn.lock diff --git a/lib/DocGenerator b/lib/DocGenerator deleted file mode 160000 index fd0390f8..00000000 --- a/lib/DocGenerator +++ /dev/null @@ -1 +0,0 @@ -Subproject commit fd0390f8629d405f7b0c6dded92998dd56d82a77 diff --git a/lib/DocGenerator/.github/workflows/run-test.yml b/lib/DocGenerator/.github/workflows/run-test.yml new file mode 100644 index 00000000..98f12398 --- /dev/null +++ b/lib/DocGenerator/.github/workflows/run-test.yml @@ -0,0 +1,41 @@ +name: Unit tests, static analysis and code style + +on: + push: + branches: + - develop + - 'release/**' + - 'hotfix/**' + tags: ['**'] + pull_request: + types: + - opened + - reopened + - synchronize + - ready_for_review + +jobs: + run-tests: + runs-on: ubuntu-latest + strategy: + matrix: + php-version: ['8.0', '8.1'] + steps: + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + - uses: actions/checkout@v3 + - name: Cache Composer packages + id: composer-cache + uses: actions/cache@v3 + with: + path: vendor + key: ${{ runner.os }}-php-${{ matrix.php-version }}-${{ hashFiles('**/composer.lock') }} + restore-keys: | + ${{ runner.os }}-php-${{ matrix.php-version }}- + - name: Install Dependencies + run: composer install --no-scripts --no-ansi --no-interaction --no-progress + - name: Run PHP Code Sniffer + run: vendor/bin/phpcs -p ./src + - name: Run PHPStan + run: vendor/bin/phpstan analyse --no-progress -c phpstan.neon diff --git a/lib/DocGenerator/.gitignore b/lib/DocGenerator/.gitignore new file mode 100644 index 00000000..853e2f14 --- /dev/null +++ b/lib/DocGenerator/.gitignore @@ -0,0 +1,8 @@ +composer.phar +/vendor/ + +report.txt +# Commit your application's lock file https://getcomposer.org/doc/01-basic-usage.md#commit-your-composer-lock-file-to-version-control +# You may choose to ignore a library lock file http://getcomposer.org/doc/02-libraries.md#lock-file +composer.lock +.phpcs-cache \ No newline at end of file diff --git a/lib/DocGenerator/.travis.yml b/lib/DocGenerator/.travis.yml new file mode 100644 index 00000000..eb3285db --- /dev/null +++ b/lib/DocGenerator/.travis.yml @@ -0,0 +1,15 @@ +language: php +sudo: required +php: + - 8.0 + - 8.1 + - nightly +install: + - curl -s http://getcomposer.org/installer | php + - php composer.phar install --dev --no-interaction +script: + - vendor/bin/phpcs --report=full --report-file=./report.txt -p ./ + - vendor/bin/phpstan analyse -c phpstan.neon +jobs: + allow_failures: + - php: nightly diff --git a/lib/DocGenerator/CHANGELOG.md b/lib/DocGenerator/CHANGELOG.md new file mode 100644 index 00000000..da392720 --- /dev/null +++ b/lib/DocGenerator/CHANGELOG.md @@ -0,0 +1,6 @@ +## 2.0.0 (2022-06-28) + +### Features + +* Removed *Pimple* dependency, psr12 and code smell ([e3065f0](https://github.com/roadiz/doc-generator/commit/e3065f063bc2c4194868584d03903e197f7bdaed)) + diff --git a/lib/DocGenerator/LICENSE b/lib/DocGenerator/LICENSE new file mode 100644 index 00000000..90114a25 --- /dev/null +++ b/lib/DocGenerator/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Ambroise Maupate and Julien Blanchet + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/lib/DocGenerator/Makefile b/lib/DocGenerator/Makefile new file mode 100644 index 00000000..79021819 --- /dev/null +++ b/lib/DocGenerator/Makefile @@ -0,0 +1,4 @@ +test: + vendor/bin/phpcs --report=full --report-file=./report.txt -p ./ + vendor/bin/phpstan analyse -c phpstan.neon + diff --git a/lib/DocGenerator/README.md b/lib/DocGenerator/README.md new file mode 100644 index 00000000..5dfcdc50 --- /dev/null +++ b/lib/DocGenerator/README.md @@ -0,0 +1,4 @@ +# doc-generator +Roadiz sub-package which generates Markdown documentation skeleton based on your schema + +[![Unit tests, static analysis and code style](https://github.com/roadiz/doc-generator/actions/workflows/run-test.yml/badge.svg?branch=develop)](https://github.com/roadiz/doc-generator/actions/workflows/run-test.yml) diff --git a/lib/DocGenerator/composer.json b/lib/DocGenerator/composer.json new file mode 100644 index 00000000..6b13f03c --- /dev/null +++ b/lib/DocGenerator/composer.json @@ -0,0 +1,35 @@ +{ + "name": "roadiz/doc-generator", + "description": "Roadiz sub-package which generates Markdown documentation skeleton based on your schema", + "type": "library", + "require": { + "php": ">=8.0", + "roadiz/nodetype-contracts": "~1.1.2", + "symfony/translation": "5.4.*", + "symfony/http-foundation": "5.4.*" + }, + "license": "MIT", + "authors": [ + { + "name": "Ambroise Maupate", + "email": "ambroise@roadiz.io", + "homepage": "https://www.roadiz.io", + "role": "Lead developer" + } + ], + "autoload": { + "psr-4": { + "RZ\\Roadiz\\Documentation\\": "src/" + } + }, + "require-dev": { + "phpstan/phpstan": "^1.5.3", + "squizlabs/php_codesniffer": "^3.5" + }, + "extra": { + "branch-alias": { + "dev-main": "2.0.x-dev", + "dev-develop": "2.1.x-dev" + } + } +} diff --git a/lib/DocGenerator/phpcs.xml.dist b/lib/DocGenerator/phpcs.xml.dist new file mode 100644 index 00000000..97e3c88c --- /dev/null +++ b/lib/DocGenerator/phpcs.xml.dist @@ -0,0 +1,17 @@ + + + + + + + + + + + + ./src + */node_modules + */.AppleDouble + */vendor + diff --git a/lib/DocGenerator/phpstan.neon b/lib/DocGenerator/phpstan.neon new file mode 100644 index 00000000..2359c3d9 --- /dev/null +++ b/lib/DocGenerator/phpstan.neon @@ -0,0 +1,7 @@ +parameters: + level: max + paths: + - src + reportUnmatchedIgnoredErrors: false + checkGenericClassInNonGenericObjectType: false + checkMissingIterableValueType: false diff --git a/lib/DocGenerator/src/Generators/AbstractFieldGenerator.php b/lib/DocGenerator/src/Generators/AbstractFieldGenerator.php new file mode 100644 index 00000000..9cbd27ca --- /dev/null +++ b/lib/DocGenerator/src/Generators/AbstractFieldGenerator.php @@ -0,0 +1,68 @@ +field = $field; + $this->nodeTypesBag = $nodeTypesBag; + $this->translator = $translator; + $this->markdownGeneratorFactory = $fieldGeneratorFactory; + } + + abstract public function getContents(): string; + + /** + * @return string + */ + public function getIntroduction(): string + { + $lines = [ + '### ' . $this->field->getLabel(), + ]; + if (!empty($this->field->getDescription())) { + $lines[] = $this->field->getDescription(); + } + $lines = array_merge($lines, [ + '', + '| | |', + '| --- | --- |', + '| **' . trim($this->translator->trans('docs.type')) . '** | ' . $this->translator->trans($this->field->getTypeName()) . ' |', + '| **' . trim($this->translator->trans('docs.technical_name')) . '** | `' . $this->field->getVarName() . '` |', + '| **' . trim($this->translator->trans('docs.universal')) . '** | *' . $this->markdownGeneratorFactory->getHumanBool($this->field->isUniversal()) . '* |', + ]); + + if (!empty($this->field->getGroupName())) { + $lines[] = '| **' . trim($this->translator->trans('docs.group')) . '** | ' . $this->field->getGroupName() . ' |'; + } + + if (!$this->field->isVisible()) { + $lines[] = '| **' . trim($this->translator->trans('docs.visible')) . '** | *' . $this->markdownGeneratorFactory->getHumanBool($this->field->isVisible()) . '* |'; + } + + return implode("\n", $lines) . "\n"; + } +} diff --git a/lib/DocGenerator/src/Generators/ChildrenNodeFieldGenerator.php b/lib/DocGenerator/src/Generators/ChildrenNodeFieldGenerator.php new file mode 100644 index 00000000..ba5fe902 --- /dev/null +++ b/lib/DocGenerator/src/Generators/ChildrenNodeFieldGenerator.php @@ -0,0 +1,44 @@ +getIntroduction(), + '#### ' . $this->translator->trans('docs.available_children_blocks'), + $this->getAvailableChildren() + ]); + } + + /** + * @return array + */ + protected function getChildrenNodeTypes(): array + { + if (null !== $this->field->getDefaultValues()) { + return array_filter(array_map(function (string $nodeTypeName) { + $nodeType = $this->nodeTypesBag->get(trim($nodeTypeName)); + return $nodeType instanceof NodeTypeInterface ? $nodeType : null; + }, explode(',', $this->field->getDefaultValues()))); + } + return []; + } + + protected function getAvailableChildren(): string + { + return implode("\n", array_map(function (NodeTypeInterface $nodeType) { + $nodeTypeGenerator = $this->markdownGeneratorFactory->createForNodeType($nodeType); + return implode("\n", [ + '* **' . trim($nodeTypeGenerator->getMenuEntry()) . '** ', + $nodeType->getDescription(), + ]); + }, $this->getChildrenNodeTypes())) . "\n"; + } +} diff --git a/lib/DocGenerator/src/Generators/CommonFieldGenerator.php b/lib/DocGenerator/src/Generators/CommonFieldGenerator.php new file mode 100644 index 00000000..e6c96b09 --- /dev/null +++ b/lib/DocGenerator/src/Generators/CommonFieldGenerator.php @@ -0,0 +1,15 @@ +getIntroduction() + ]); + } +} diff --git a/lib/DocGenerator/src/Generators/DefaultValuedFieldGenerator.php b/lib/DocGenerator/src/Generators/DefaultValuedFieldGenerator.php new file mode 100644 index 00000000..32e14c58 --- /dev/null +++ b/lib/DocGenerator/src/Generators/DefaultValuedFieldGenerator.php @@ -0,0 +1,25 @@ +getIntroduction(), + $this->getDefaultValues() + ]); + } + + private function getDefaultValues(): string + { + return implode("\n", array_map(function (string $value) { + return implode("\n", [ + '* **' . trim($this->translator->trans(trim($value))) . '** `' . $value . '`', + ]); + }, explode(',', $this->field->getDefaultValues() ?? ''))) . "\n"; + } +} diff --git a/lib/DocGenerator/src/Generators/DocumentationGenerator.php b/lib/DocGenerator/src/Generators/DocumentationGenerator.php new file mode 100644 index 00000000..06616ac3 --- /dev/null +++ b/lib/DocGenerator/src/Generators/DocumentationGenerator.php @@ -0,0 +1,112 @@ +nodeTypesBag = $nodeTypesBag; + $this->translator = $translator; + $this->markdownGeneratorFactory = new MarkdownGeneratorFactory($nodeTypesBag, $translator); + } + + /** + * @return array + */ + protected function getAllNodeTypes(): array + { + return array_unique($this->nodeTypesBag->all()); + } + + /** + * @return array + */ + protected function getReachableTypes(): array + { + return array_filter($this->getAllNodeTypes(), function (NodeTypeInterface $nodeType) { + return $nodeType->isReachable(); + }); + } + + /** + * @return array + */ + protected function getNonReachableTypes(): array + { + return array_filter($this->getAllNodeTypes(), function (NodeTypeInterface $nodeType) { + return !$nodeType->isReachable(); + }); + } + + /** + * @return NodeTypeGenerator[] + */ + public function getReachableTypeGenerators(): array + { + if (null === $this->reachableTypeGenerators) { + $this->reachableTypeGenerators = array_map(function (NodeTypeInterface $nodeType) { + return $this->markdownGeneratorFactory->createForNodeType($nodeType); + }, $this->getReachableTypes()); + } + return $this->reachableTypeGenerators; + } + + /** + * @return NodeTypeGenerator[] + */ + public function getNonReachableTypeGenerators(): array + { + if (null === $this->nonReachableTypeGenerators) { + $this->nonReachableTypeGenerators = array_map(function (NodeTypeInterface $nodeType) { + return $this->markdownGeneratorFactory->createForNodeType($nodeType); + }, $this->getNonReachableTypes()); + } + return $this->nonReachableTypeGenerators; + } + + public function getNavBar(): string + { + /* + * + + * [Introduction](/) + * Blocs + * [Groupe de blocs](blocks/groupblock.md) + * [Bloc de contenu](blocks/contentblock.md) + */ + + $pages = []; + foreach ($this->getReachableTypeGenerators() as $reachableTypeGenerator) { + $pages[] = $reachableTypeGenerator->getMenuEntry(); + } + + $blocks = []; + foreach ($this->getNonReachableTypeGenerators() as $nonReachableTypeGenerator) { + $blocks[] = $nonReachableTypeGenerator->getMenuEntry(); + } + + return implode("\n", [ + '* ' . $this->translator->trans('docs.pages'), + " * " . implode("\n * ", $pages), + '* ' . $this->translator->trans('docs.blocks'), + " * " . implode("\n * ", $blocks) + ]); + } +} diff --git a/lib/DocGenerator/src/Generators/MarkdownGeneratorFactory.php b/lib/DocGenerator/src/Generators/MarkdownGeneratorFactory.php new file mode 100644 index 00000000..b2772982 --- /dev/null +++ b/lib/DocGenerator/src/Generators/MarkdownGeneratorFactory.php @@ -0,0 +1,65 @@ +nodeTypesBag = $nodeTypesBag; + $this->translator = $translator; + } + + public function getHumanBool(bool $bool): string + { + return $bool ? $this->translator->trans('docs.yes') : $this->translator->trans('docs.no'); + } + + /** + * @param NodeTypeInterface $nodeType + * + * @return NodeTypeGenerator + */ + public function createForNodeType(NodeTypeInterface $nodeType): NodeTypeGenerator + { + return new NodeTypeGenerator( + $nodeType, + $this->translator, + $this + ); + } + + /** + * @param NodeTypeFieldInterface $field + * + * @return AbstractFieldGenerator + */ + public function createForNodeTypeField(NodeTypeFieldInterface $field): AbstractFieldGenerator + { + switch (true) { + case $field->isNodes(): + return new NodeReferencesFieldGenerator($this, $field, $this->nodeTypesBag, $this->translator); + case $field->isChildrenNodes(): + return new ChildrenNodeFieldGenerator($this, $field, $this->nodeTypesBag, $this->translator); + case $field->isMultiple(): + case $field->isEnum(): + return new DefaultValuedFieldGenerator($this, $field, $this->nodeTypesBag, $this->translator); + default: + return new CommonFieldGenerator($this, $field, $this->nodeTypesBag, $this->translator); + } + } +} diff --git a/lib/DocGenerator/src/Generators/NodeReferencesFieldGenerator.php b/lib/DocGenerator/src/Generators/NodeReferencesFieldGenerator.php new file mode 100644 index 00000000..8bfe44d5 --- /dev/null +++ b/lib/DocGenerator/src/Generators/NodeReferencesFieldGenerator.php @@ -0,0 +1,17 @@ +getIntroduction(), + '#### ' . $this->translator->trans('docs.available_referenced_nodes'), + $this->getAvailableChildren() + ]); + } +} diff --git a/lib/DocGenerator/src/Generators/NodeTypeGenerator.php b/lib/DocGenerator/src/Generators/NodeTypeGenerator.php new file mode 100644 index 00000000..4a98426d --- /dev/null +++ b/lib/DocGenerator/src/Generators/NodeTypeGenerator.php @@ -0,0 +1,97 @@ +nodeType = $nodeType; + $this->fieldGenerators = []; + $this->translator = $translator; + $this->markdownGeneratorFactory = $markdownGeneratorFactory; + + /** @var NodeTypeFieldInterface $field */ + foreach ($this->nodeType->getFields() as $field) { + $this->fieldGenerators[] = $this->markdownGeneratorFactory->createForNodeTypeField($field); + } + } + + public function getMenuEntry(): string + { + return '[' . $this->nodeType->getLabel() . '](' . $this->getPath() . ')'; + } + + public function getType(): string + { + return $this->nodeType->isReachable() ? 'page' : 'block'; + } + + public function getPath(): string + { + return $this->getType() . '/' . $this->nodeType->getName() . '.md'; + } + + public function getContents(): string + { + return implode("\n\n", [ + $this->getIntroduction(), + '## ' . $this->translator->trans('docs.fields'), + $this->getFieldsContents() + ]); + } + + protected function getIntroduction(): string + { + $lines = [ + '# ' . $this->nodeType->getLabel(), + ]; + if (!empty($this->nodeType->getDescription())) { + $lines[] = $this->nodeType->getDescription(); + } + $lines = array_merge($lines, [ + '', + '| | |', + '| --- | --- |', + '| **' . trim($this->translator->trans('docs.technical_name')) . '** | `' . $this->nodeType->getName() . '` |', + ]); + + if ($this->nodeType->isPublishable()) { + $lines[] = '| **' . trim($this->translator->trans('docs.publishable')) . '** | *' . $this->markdownGeneratorFactory->getHumanBool($this->nodeType->isPublishable()) . '* |'; + } + if (!$this->nodeType->isVisible()) { + $lines[] = '| **' . trim($this->translator->trans('docs.visible')) . '** | *' . $this->markdownGeneratorFactory->getHumanBool($this->nodeType->isVisible()) . '* |'; + } + + return implode("\n", $lines); + } + + protected function getFieldsContents(): string + { + return implode("\n", array_map(function (AbstractFieldGenerator $abstractFieldGenerator) { + return $abstractFieldGenerator->getContents(); + }, $this->fieldGenerators)); + } +} diff --git a/lib/DocGenerator/src/Resources/translations/messages.ar.xlf b/lib/DocGenerator/src/Resources/translations/messages.ar.xlf new file mode 100644 index 00000000..f5bf5e9d --- /dev/null +++ b/lib/DocGenerator/src/Resources/translations/messages.ar.xlf @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/lib/DocGenerator/src/Resources/translations/messages.de.xlf b/lib/DocGenerator/src/Resources/translations/messages.de.xlf new file mode 100644 index 00000000..2704066d --- /dev/null +++ b/lib/DocGenerator/src/Resources/translations/messages.de.xlf @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/lib/DocGenerator/src/Resources/translations/messages.en.xlf b/lib/DocGenerator/src/Resources/translations/messages.en.xlf new file mode 100644 index 00000000..6269b33b --- /dev/null +++ b/lib/DocGenerator/src/Resources/translations/messages.en.xlf @@ -0,0 +1,63 @@ + + + + + + docs.yes + Yes + + + docs.no + No + + + docs.available_children_blocks + Available children blocks + + + docs.type + Type + + + docs.universal + Universal + + + docs.technical_name + Technical name + + + docs.group + Group + + + docs.excluded_from_search + Excluded from search engine + + + docs.visible + Visible + + + docs.pages + Pages + + + docs.blocks + Blocks + + + docs.available_referenced_nodes + Available referenced nodes + + + docs.fields + Fields + + + docs.publishable + Date and time publishable + + + + diff --git a/lib/DocGenerator/src/Resources/translations/messages.es.xlf b/lib/DocGenerator/src/Resources/translations/messages.es.xlf new file mode 100644 index 00000000..52ab9716 --- /dev/null +++ b/lib/DocGenerator/src/Resources/translations/messages.es.xlf @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/lib/DocGenerator/src/Resources/translations/messages.fr.xlf b/lib/DocGenerator/src/Resources/translations/messages.fr.xlf new file mode 100644 index 00000000..190c2e59 --- /dev/null +++ b/lib/DocGenerator/src/Resources/translations/messages.fr.xlf @@ -0,0 +1,63 @@ + + + + + + docs.yes + Oui + + + docs.no + Non + + + docs.available_children_blocks + Blocs-enfants disponibles + + + docs.type + Type + + + docs.universal + Universel + + + docs.technical_name + Nom technique + + + docs.group + Groupe + + + docs.excluded_from_search + Exclu du moteur de recherche + + + docs.visible + Visible + + + docs.pages + Pages + + + docs.blocks + Blocs + + + docs.available_referenced_nodes + Références aux nœuds disponibles + + + docs.fields + Champs + + + docs.publishable + Publiable par date et heure + + + + diff --git a/lib/DocGenerator/src/Resources/translations/messages.id.xlf b/lib/DocGenerator/src/Resources/translations/messages.id.xlf new file mode 100644 index 00000000..8de4aeb7 --- /dev/null +++ b/lib/DocGenerator/src/Resources/translations/messages.id.xlf @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/lib/DocGenerator/src/Resources/translations/messages.it.xlf b/lib/DocGenerator/src/Resources/translations/messages.it.xlf new file mode 100644 index 00000000..d6f844ce --- /dev/null +++ b/lib/DocGenerator/src/Resources/translations/messages.it.xlf @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/lib/DocGenerator/src/Resources/translations/messages.ru.xlf b/lib/DocGenerator/src/Resources/translations/messages.ru.xlf new file mode 100644 index 00000000..83ff0399 --- /dev/null +++ b/lib/DocGenerator/src/Resources/translations/messages.ru.xlf @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/lib/DocGenerator/src/Resources/translations/messages.sr.xlf b/lib/DocGenerator/src/Resources/translations/messages.sr.xlf new file mode 100644 index 00000000..be5ebdfb --- /dev/null +++ b/lib/DocGenerator/src/Resources/translations/messages.sr.xlf @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/lib/DocGenerator/src/Resources/translations/messages.tr.xlf b/lib/DocGenerator/src/Resources/translations/messages.tr.xlf new file mode 100644 index 00000000..e18d80e0 --- /dev/null +++ b/lib/DocGenerator/src/Resources/translations/messages.tr.xlf @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/lib/DocGenerator/src/Resources/translations/messages.uk.xlf b/lib/DocGenerator/src/Resources/translations/messages.uk.xlf new file mode 100644 index 00000000..b70d76a1 --- /dev/null +++ b/lib/DocGenerator/src/Resources/translations/messages.uk.xlf @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/lib/DocGenerator/src/Resources/translations/messages.xlf b/lib/DocGenerator/src/Resources/translations/messages.xlf new file mode 100644 index 00000000..e095dbf2 --- /dev/null +++ b/lib/DocGenerator/src/Resources/translations/messages.xlf @@ -0,0 +1,63 @@ + + + + + + docs.yes + + + + docs.no + + + + docs.available_children_blocks + + + + docs.type + + + + docs.universal + + + + docs.technical_name + + + + docs.group + + + + docs.excluded_from_search + + + + docs.visible + + + + docs.pages + + + + docs.blocks + + + + docs.available_referenced_nodes + + + + docs.fields + + + + docs.publishable + + + + + diff --git a/lib/DocGenerator/src/Resources/translations/messages.zh.xlf b/lib/DocGenerator/src/Resources/translations/messages.zh.xlf new file mode 100644 index 00000000..ef561253 --- /dev/null +++ b/lib/DocGenerator/src/Resources/translations/messages.zh.xlf @@ -0,0 +1,63 @@ + + + + + + docs.yes + + + + docs.no + + + + docs.available_children_blocks + 可用的子块 + + + docs.type + 类型 + + + docs.universal + 全局 + + + docs.technical_name + 专业名称 + + + docs.group + 群组 + + + docs.excluded_from_search + 除了搜索引擎 + + + docs.visible + 可见的 + + + docs.pages + 页面 + + + docs.blocks + + + + docs.available_referenced_nodes + 可用的参考节点 + + + docs.fields + 字段 + + + docs.publishable + 可发布的日期及时间 + + + + diff --git a/lib/Documents b/lib/Documents deleted file mode 160000 index bc335d6a..00000000 --- a/lib/Documents +++ /dev/null @@ -1 +0,0 @@ -Subproject commit bc335d6a65f3caaf3548303b1a65e670e7e799f1 diff --git a/lib/Documents/.editorconfig b/lib/Documents/.editorconfig new file mode 100644 index 00000000..c3ccb818 --- /dev/null +++ b/lib/Documents/.editorconfig @@ -0,0 +1,17 @@ +# Roadiz editor config for contributors +# http://editorconfig.org/ +root = true + +[*] +indent_style = space +indent_size = 4 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.md] +trim_trailing_whitespace = false + +[Makefile] +indent_style = tab diff --git a/lib/Documents/.github/workflows/run-test.yml b/lib/Documents/.github/workflows/run-test.yml new file mode 100644 index 00000000..01c564c1 --- /dev/null +++ b/lib/Documents/.github/workflows/run-test.yml @@ -0,0 +1,43 @@ +name: Unit tests, static analysis and code style + +on: + push: + branches: + - develop + - 'release/**' + - 'hotfix/**' + tags: ['**'] + pull_request: + types: + - opened + - reopened + - synchronize + - ready_for_review + +jobs: + run-tests: + runs-on: ubuntu-latest + strategy: + matrix: + php-version: ['8.0', '8.1'] + steps: + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + - uses: actions/checkout@v3 + - name: Cache Composer packages + id: composer-cache + uses: actions/cache@v3 + with: + path: vendor + key: ${{ runner.os }}-php-${{ matrix.php-version }}-${{ hashFiles('**/composer.lock') }} + restore-keys: | + ${{ runner.os }}-php-${{ matrix.php-version }}- + - name: Install Dependencies + run: composer install --no-scripts --no-ansi --no-interaction --no-progress + - name: Run atoum unit tests + run: vendor/bin/atoum -d tests + - name: Run PHP Code Sniffer + run: vendor/bin/phpcs -p ./src + - name: Run PHPStan + run: vendor/bin/phpstan analyse --no-progress -c phpstan.neon diff --git a/lib/Documents/.gitignore b/lib/Documents/.gitignore new file mode 100644 index 00000000..8a7ba18c --- /dev/null +++ b/lib/Documents/.gitignore @@ -0,0 +1,110 @@ +# +# Roadiz +# +/pimple.json + +# Ignore Google webmaster tool verification +/google*.html + +# PHPCS report +/report.txt +/score.txt + +# Cloverage build folder +/build + +# Enable favicon customisation +/favicon.ico + +# Apache files +.htpasswd +.htaccess + +# Some old css tricks for IE +*.htc + +# Created by https://www.gitignore.io + +### SublimeText ### +# cache files for sublime text +*.tmlanguage.cache +*.tmPreferences.cache +*.stTheme.cache + +# workspace files are user-specific +*.sublime-workspace + +# project files should be checked into the repository, unless a significant +# proportion of contributors will probably not be using SublimeText +# *.sublime-project + +# sftp configuration file +sftp-config.json + + +### Composer ### +composer.phar +/vendor + +# Commit your application's lock file http://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file +# You may choose to ignore a library lock file http://getcomposer.org/doc/02-libraries.md#lock-file +composer.lock + +### Node ### +# Logs +logs +*.log + +# Except for logs folder +!/logs + +# Runtime data +pids +*.pid +*.seed + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +.php_cs.cache + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release + +# Dependency directory +# https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git +node_modules + +### Vagrant ### +/Vagrantfile +.vagrant/ + +### grunt ### +# Grunt usually compiles files inside this directory +dist/ + +# Grunt usually preprocesses files such as coffeescript, compass... inside the .tmp directory +.tmp/ +testDir/ + +## PHPStorm +.idea + +## XDebug profile folder +/.xdebug + +### Bower ### +bower_components +.bower-cache +.bower-registry +.bower-tmp +/.phpcs-cache diff --git a/lib/Documents/.travis.yml b/lib/Documents/.travis.yml new file mode 100644 index 00000000..61a6d14f --- /dev/null +++ b/lib/Documents/.travis.yml @@ -0,0 +1,19 @@ +language: php +sudo: required +php: + - 7.4 + - 8.0 + - 8.1 + - nightly +env: + - XDEBUG_MODE=coverage +install: + - curl -s http://getcomposer.org/installer | php + - php composer.phar install --dev --no-interaction +script: + - vendor/bin/phpcs --report=full --report-file=./report.txt -p ./src + - vendor/bin/atoum -d tests + - vendor/bin/phpstan analyse -c phpstan.neon +jobs: + allow_failures: + - php: nightly diff --git a/lib/Documents/CHANGELOG.md b/lib/Documents/CHANGELOG.md new file mode 100644 index 00000000..ffbefbea --- /dev/null +++ b/lib/Documents/CHANGELOG.md @@ -0,0 +1,56 @@ +## 2.0.7 (2022-10-07) + +### Bug Fixes + +* Allow roadiz/models to dev-develop ([f826244](https://github.com/roadiz/documents/commit/f826244ab56492bb307ce9a1584801e1953cf06b)) + +## 2.0.6 (2022-09-28) + +### Features + +* **Events:** Deprecated all documents upload events which are dispatched before DB flush. ([7f929c9](https://github.com/roadiz/documents/commit/7f929c96d2d97353b191a0a26ab27c1a8cd14f74)) + +## 2.0.5 (2022-09-07) + +### Bug Fixes + +* Missing image file extensions in DownscaleImageManager ([7572026](https://github.com/roadiz/documents/commit/75720264bff67bbf6932d0cac04db46eadd91406)) + +## 2.0.4 (2022-09-07) + +### ⚠ BREAKING CHANGES + +* `DownscaleImageManager` constructor signature changed and requires a `ImageManager` object + +### Bug Fixes + +* Do not instanciate new ImageManager, just pass it as constructor arg ([11bffec](https://github.com/roadiz/documents/commit/11bffec3a19dc91f16a544265d4288fe3602cbcf)) + +## 2.0.3 (2022-09-07) + +### Features + +* Added `image/heic` and `image/heif` mime type to image, deprecated document event subscribers ([d58b08a](https://github.com/roadiz/documents/commit/d58b08a4f73d5f3986881863729c9a9b9321dfa5)) + +## 2.0.2 (2022-07-29) + +### Features + +* Added AbstractDocumentFinder to hold video, audio and picture document finding logic ([9f7d1bd](https://github.com/roadiz/documents/commit/9f7d1bdb68ea6c8e33ff6228652683d1673c58a2)) + +## 2.0.1 (2022-06-30) + +## 2.0.0 (2022-06-29) + +### Features + +* Added FileHashInterface to documents to store their file hash for duplicates detection. ([18edef5](https://github.com/roadiz/documents/commit/18edef58ef0c1bdfea8cf78404e58c33169e1f1f)) +* Support readId patterns for Spotify and Deezer embed platforms ([fd2c1ab](https://github.com/roadiz/documents/commit/fd2c1ab18d220322417973c1b71bfa28b304c1ed)) +* Update document file hash when downscaled/upscaled ([039d3af](https://github.com/roadiz/documents/commit/039d3af9f8adbe0e9a5fe3d974d7f59776478595)) +* Updated dependencies ([ca34c19](https://github.com/roadiz/documents/commit/ca34c1955528f41acdb2814e12f12aca14e66b18)) +* Use CacheItemPoolInterface in AbstractDocumentUrlGenerator, phpcs ([b52054b](https://github.com/roadiz/documents/commit/b52054b50414f95d768fb8e2195853f8e3a958af)) + +### Bug Fixes + +* Fix nullable setOriginal method arg ([173338c](https://github.com/roadiz/documents/commit/173338c0c6f7b4a1d3725998f5df393aae620c29)) + diff --git a/lib/Documents/LICENSE.md b/lib/Documents/LICENSE.md new file mode 100644 index 00000000..d4d8a009 --- /dev/null +++ b/lib/Documents/LICENSE.md @@ -0,0 +1,9 @@ +The MIT License (MIT) + +Copyright © 2023 Ambroise Maupate + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/lib/Documents/Makefile b/lib/Documents/Makefile new file mode 100644 index 00000000..8858814e --- /dev/null +++ b/lib/Documents/Makefile @@ -0,0 +1,13 @@ +test: + vendor/bin/atoum -d tests + vendor/bin/phpstan analyse -c phpstan.neon + vendor/bin/phpcs --report=full --report-file=./report.txt -p ./src + +dev-test: + vendor/bin/atoum -d tests -l + +phpcs: + vendor/bin/phpcs --report=full --report-file=./report.txt -p ./src + +phpcbf: + vendor/bin/phpcbf --report=full --report-file=./report.txt -p ./src diff --git a/lib/Documents/README.md b/lib/Documents/README.md new file mode 100644 index 00000000..ea395ecb --- /dev/null +++ b/lib/Documents/README.md @@ -0,0 +1,20 @@ +# Roadiz Document base system + +![Run test status](https://github.com/roadiz/documents/actions/workflows/run-test.yml/badge.svg?branch=develop) + +## HTML templates + +You can override and inherit from document rendering templates by creating them in your theme at the same +path inside your `views/` folder. + +### VueJS and \ + +You may need to override ` + {% endblock %} + {% endif %} +{% endapply %} diff --git a/lib/Documents/src/Resources/views/documents/pdf.html.twig b/lib/Documents/src/Resources/views/documents/pdf.html.twig new file mode 100644 index 00000000..457f7566 --- /dev/null +++ b/lib/Documents/src/Resources/views/documents/pdf.html.twig @@ -0,0 +1,37 @@ +{% apply spaceless %} + {% set attributes = { + 'type': "application/pdf"|escape('html_attr'), + 'data': url, + } %} + {% if width %} + {% set attributes = attributes|merge({ + 'width': width|escape('html_attr'), + }) %} + {% endif %} + {% if height %} + {% set attributes = attributes|merge({ + 'height': height|escape('html_attr'), + }) %} + {% endif %} + {% if class %} + {% set attributes = attributes|merge({ + 'class': class|escape('html_attr'), + }) %} + {% endif %} + {% if identifier %} + {% set attributes = attributes|merge({ + 'id': identifier|escape('html_attr'), + }) %} + {% endif %} + {% set attributesCompiled = {} %} + {% for key, value in attributes %} + {% if value is same as(true) %} + {% set attributesCompiled = attributesCompiled|merge([key]) %} + {% else %} + {% set attributesCompiled = attributesCompiled|merge([key ~ '="' ~ value ~ '"']) %} + {% endif %} + {% endfor %} + + {%- block pdf_fallback -%}

Your browser does not support PDF native viewer.

{%- endblock -%} +
+{% endapply %} diff --git a/lib/Documents/src/Resources/views/documents/picture-inner.html.twig b/lib/Documents/src/Resources/views/documents/picture-inner.html.twig new file mode 100644 index 00000000..fcd2431f --- /dev/null +++ b/lib/Documents/src/Resources/views/documents/picture-inner.html.twig @@ -0,0 +1,17 @@ +{# Picture element should not declare any attributes #} + + {%- if not mediaList -%} + {%- include 'documents/picture-source.html.twig' -%} + {%- else -%} + {%- for media in mediaList -%} + {%- include 'documents/picture-source.html.twig' with { + 'srcset': media.srcset, + 'webp_srcset': media.webp_srcset, + 'media': media.rule + } -%} + {%- endfor -%} + {%- endif -%} + {%- include 'documents/image.html.twig' with { + 'avoidNoScript': true + } -%} + diff --git a/lib/Documents/src/Resources/views/documents/picture-source.html.twig b/lib/Documents/src/Resources/views/documents/picture-source.html.twig new file mode 100644 index 00000000..29298548 --- /dev/null +++ b/lib/Documents/src/Resources/views/documents/picture-source.html.twig @@ -0,0 +1,62 @@ +{% import _self as macro %} + +{%- macro source(sizes, media, srcset, datasrcset, type, noescape) -%} + {% set attributes = { + 'type': type|escape('html_attr') + } %} + {% if sizes %} + {% set attributes = attributes|merge({ + 'sizes': sizes + }) %} + {% endif %} + {% if media %} + {% set attributes = attributes|merge({ + 'media': media|escape('html_attr') + }) %} + {% endif %} + {% if srcset %} + {% set attributes = attributes|merge({ + 'srcset': srcset + }) %} + {% endif %} + {% if datasrcset %} + {% set attributes = attributes|merge({ + 'data-srcset': datasrcset + }) %} + {% endif %} + {% set attributesCompiled = {} %} + {% for key, value in attributes %} + {% if value is same as(true) %} + {% set attributesCompiled = attributesCompiled|merge([key]) %} + {% else %} + {% set attributesCompiled = attributesCompiled|merge([key ~ '="' ~ value ~ '"']) %} + {% endif %} + {% endfor %} + +{%- endmacro -%} + +{% if lazyload %} + {% if srcset %} + {%- if webp_srcset -%} + {{ macro.source(sizes, media, fallback, webp_srcset, 'image/webp', noescape) }} + {%- endif -%} + {{ macro.source(sizes, media, fallback, srcset, mimetype, noescape) }} + {% else %} + {%- if not isWebp -%} + {{ macro.source(null, media, fallback, url ~ '.webp' , 'image/webp', noescape) }} + {%- endif -%} + {{ macro.source(null, media, fallback, url, mimetype, noescape) }} + {% endif %} +{% else %} + {% if srcset %} + {%- if webp_srcset -%} + {{ macro.source(sizes, media, webp_srcset, null, 'image/webp', noescape) }} + {%- endif -%} + {{ macro.source(sizes, media, srcset, null, mimetype, noescape) }} + {% else %} + {%- if not isWebp -%} + {{ macro.source(null, media, url ~ '.webp', null, 'image/webp', noescape) }} + {%- endif -%} + {{ macro.source(null, media, url, null, mimetype, noescape) }} + {% endif %} +{% endif %} diff --git a/lib/Documents/src/Resources/views/documents/picture.html.twig b/lib/Documents/src/Resources/views/documents/picture.html.twig new file mode 100644 index 00000000..3eb34e68 --- /dev/null +++ b/lib/Documents/src/Resources/views/documents/picture.html.twig @@ -0,0 +1,14 @@ +{% apply spaceless %} + +{%- include 'documents/picture-inner.html.twig' -%} +{%- if lazyload -%} +{% block noscript %} + + {%- include 'documents/picture-inner.html.twig' with { + 'lazyload': false + } -%} + +{% endblock %} +{%- endif -%} + +{% endapply %} diff --git a/lib/Documents/src/Resources/views/documents/video.html.twig b/lib/Documents/src/Resources/views/documents/video.html.twig new file mode 100644 index 00000000..c1d7c148 --- /dev/null +++ b/lib/Documents/src/Resources/views/documents/video.html.twig @@ -0,0 +1,67 @@ +{% apply spaceless %} + {% set attributes = {} %} + {% if width %} + {% set attributes = attributes|merge({ + 'width': width|escape('html_attr'), + }) %} + {% endif %} + {% if height %} + {% set attributes = attributes|merge({ + 'height': height|escape('html_attr'), + }) %} + {% endif %} + {% if class %} + {% set attributes = attributes|merge({ + 'class': class|escape('html_attr'), + }) %} + {% endif %} + {% if identifier %} + {% set attributes = attributes|merge({ + 'id': identifier|escape('html_attr'), + }) %} + {% endif %} + {# Add controls by default #} + {% if controls is same as(true) %} + {% set attributes = attributes|merge({ + 'controls': true, + }) %} + {% endif %} + {% if autoplay is same as(true) %} + {% set attributes = attributes|merge({ + 'autoplay': true, + 'playsinline': true + }) %} + {% endif %} + {% if muted is same as(true) %} + {% set attributes = attributes|merge({ + 'muted': true, + 'playsinline': true + }) %} + {% endif %} + {% if loop is same as(true) %} + {% set attributes = attributes|merge({ + 'loop': true, + }) %} + {% endif %} + {% if poster %} + {% set attributes = attributes|merge({ + 'poster': poster, + }) %} + {% endif %} + {% set attributesCompiled = {} %} + {% for key, value in attributes %} + {% if value is same as(true) %} + {% set attributesCompiled = attributesCompiled|merge([key]) %} + {% else %} + {% set attributesCompiled = attributesCompiled|merge([key ~ '="' ~ value ~ '"']) %} + {% endif %} + {% endfor %} + + {% for source in sources %} + + {% endfor %} + {% block video_fallback %} +

Your browser does not support native video.

+ {% endblock %} + +{% endapply %} diff --git a/lib/Documents/src/SvgSizeResolver.php b/lib/Documents/src/SvgSizeResolver.php new file mode 100644 index 00000000..c0f9dc13 --- /dev/null +++ b/lib/Documents/src/SvgSizeResolver.php @@ -0,0 +1,143 @@ +document = $document; + $this->documentsStorage = $documentsStorage; + } + + /** + * @return array|null [$x, $y, $width, $height] + */ + protected function getViewBoxAttributes(): ?array + { + try { + $viewBox = $this->getSvgNodeAttributes()->getNamedItem('viewBox'); + if (null !== $viewBox && $viewBox->textContent !== "") { + return explode(' ', $viewBox->textContent); + } + } catch (\RuntimeException $exception) { + return null; + } + + return null; + } + + /** + * @param string $name + * @return int|null + */ + protected function getIntegerAttribute(string $name): ?int + { + try { + $attribute = $this->getSvgNodeAttributes()->getNamedItem($name); + if ( + null !== $attribute + && $attribute->textContent !== "" + && !\str_contains($attribute->textContent, '%') + ) { + return (int) $attribute->textContent; + } + } catch (\RuntimeException $exception) { + return null; + } + return null; + } + + /** + * First, find width attr, then resolve width from viewBox. + * + * @return int + */ + public function getWidth(): int + { + $widthAttr = $this->getIntegerAttribute('width'); + if (null !== $widthAttr) { + return $widthAttr; + } + + $viewBoxAttr = $this->getViewBoxAttributes(); + if (null !== $viewBoxAttr) { + [$x, $y, $width, $height] = $viewBoxAttr; + return (int) $width; + } + + return 0; + } + + /** + * First, find height attr, then resolve height from viewBox. + * + * @return int + */ + public function getHeight(): int + { + $heightAttr = $this->getIntegerAttribute('height'); + if (null !== $heightAttr) { + return $heightAttr; + } + $viewBoxAttr = $this->getViewBoxAttributes(); + if (null !== $viewBoxAttr) { + [$x, $y, $width, $height] = $viewBoxAttr; + return (int) $height; + } + + return 0; + } + + private function getSvgNode(): \DOMElement + { + if (null === $this->svgNode) { + $svg = $this->getDOMDocument()->getElementsByTagName('svg'); + if (!isset($svg[0])) { + throw new \RuntimeException('SVG does not contain a valid tag'); + } + $this->svgNode = $svg[0]; + } + + return $this->svgNode; + } + + private function getSvgNodeAttributes(): \DOMNamedNodeMap + { + if (null === $this->getSvgNode()->attributes) { + throw new \RuntimeException('SVG tag does not contain any attribute'); + } + + return $this->getSvgNode()->attributes; + } + + /** + * @throws FilesystemException + */ + private function getDOMDocument(): \DOMDocument + { + if (null === $this->xmlDocument) { + $mountPath = $this->document->getMountPath(); + if (null === $mountPath) { + throw new \RuntimeException('SVG does not have file.'); + } + $this->xmlDocument = new \DOMDocument(); + $svgSource = $this->documentsStorage->read($mountPath); + if (false === $this->xmlDocument->loadXML($svgSource)) { + throw new \RuntimeException(sprintf('SVG (%s) could not be loaded.', $mountPath)); + } + } + return $this->xmlDocument; + } +} diff --git a/lib/Documents/src/TwigExtension/DocumentExtension.php b/lib/Documents/src/TwigExtension/DocumentExtension.php new file mode 100644 index 00000000..5b392973 --- /dev/null +++ b/lib/Documents/src/TwigExtension/DocumentExtension.php @@ -0,0 +1,245 @@ +throwExceptions = $throwExceptions; + $this->renderer = $renderer; + $this->embedFinderFactory = $embedFinderFactory; + $this->documentsStorage = $documentsStorage; + } + + /** + * @return array + */ + public function getFilters(): array + { + return [ + new TwigFilter('display', [$this, 'display'], ['is_safe' => ['html']]), + new TwigFilter('imageRatio', [$this, 'getImageRatio']), + new TwigFilter('imageSize', [$this, 'getImageSize']), + new TwigFilter('imageOrientation', [$this, 'getImageOrientation']), + new TwigFilter('path', [$this, 'getPath']), + new TwigFilter('exists', [$this, 'exists']), + new TwigFilter('embedFinder', [$this, 'getEmbedFinder']), + new TwigFilter('formatBytes', array($this, 'formatBytes')), + ]; + } + + /** + * @param string|int $bytes + * @param int $precision + * @return string + */ + public function formatBytes($bytes, int $precision = 2): string + { + $size = ['B','kB','MB','GB','TB','PB','EB','ZB','YB']; + $factor = floor((strlen((string) $bytes) - 1) / 3); + return sprintf("%.{$precision}f", (int) $bytes / pow(1024, $factor)) . @$size[$factor]; + } + + /** + * @param DocumentInterface|null $document + * @return null|EmbedFinderInterface + * @throws RuntimeError + */ + public function getEmbedFinder(DocumentInterface $document = null): ?EmbedFinderInterface + { + if (null === $document) { + if ($this->throwExceptions) { + throw new RuntimeError('Document can’t be null to get its EmbedFinder.'); + } else { + return null; + } + } + + try { + if ( + null !== $document->getEmbedPlatform() + && $this->embedFinderFactory->supports($document->getEmbedPlatform()) + ) { + return $this->embedFinderFactory->createForPlatform( + $document->getEmbedPlatform(), + $document->getEmbedId() + ); + } + } catch (InvalidEmbedId $embedException) { + if ($this->throwExceptions) { + throw new RuntimeError($embedException->getMessage()); + } else { + return null; + } + } + + return null; + } + + /** + * @param DocumentInterface|null $document + * @param array|null $options + * + * @return string + * @throws RuntimeError + */ + public function display(DocumentInterface $document = null, ?array $options = []): string + { + if (null === $document) { + if ($this->throwExceptions) { + throw new RuntimeError('Document can’t be null to be displayed.'); + } else { + return ""; + } + } + if (null === $options) { + $options = []; + } + try { + return $this->renderer->render($document, $options); + } catch (InvalidEmbedId $embedException) { + if ($this->throwExceptions) { + throw new RuntimeError($embedException->getMessage()); + } else { + return '

' . $embedException->getMessage() . '

'; + } + } catch (InvalidArgumentException $e) { + throw new RuntimeError($e->getMessage(), -1, null, $e); + } + } + + /** + * Get image orientation. + * + * - Return null if document is not an Image + * - Return `'landscape'` if width is higher or equal to height + * - Return `'portrait'` if height is strictly lower to width + * + * @param SizeableInterface |null $document + * @return null|string + * @throws RuntimeError + */ + public function getImageOrientation(SizeableInterface $document = null): ?string + { + if (null === $document) { + if ($this->throwExceptions) { + throw new RuntimeError('Document can’t be null to get its orientation.'); + } else { + return null; + } + } + $size = $this->getImageSize($document); + return $size['width'] >= $size['height'] ? 'landscape' : 'portrait'; + } + + /** + * @param SizeableInterface |null $document + * @return array + * @throws RuntimeError + */ + public function getImageSize(SizeableInterface $document = null): array + { + if (null === $document) { + if ($this->throwExceptions) { + throw new RuntimeError('Document can’t be null to get its size.'); + } else { + return [ + 'width' => 0, + 'height' => 0, + ]; + } + } + return [ + 'width' => $document->getImageWidth(), + 'height' => $document->getImageHeight(), + ]; + } + + /** + * @param SizeableInterface|null $document + * @return float + * @throws RuntimeError + */ + public function getImageRatio(SizeableInterface $document = null): float + { + if (null === $document) { + if ($this->throwExceptions) { + throw new RuntimeError('Document can’t be null to get its ratio.'); + } else { + return 0.0; + } + } + + if (null !== $document && null !== $ratio = $document->getImageRatio()) { + return $ratio; + } + + return 0.0; + } + + /** + * @param DocumentInterface|null $document + * @return null|string + */ + public function getPath(DocumentInterface $document = null): ?string + { + if ( + null !== $document && + $document->isLocal() && + !$document->isPrivate() && + null !== $mountPath = $document->getMountPath() + ) { + return $this->documentsStorage->publicUrl($mountPath); + } + + return null; + } + + /** + * @param DocumentInterface|null $document + * @return bool + * @throws FilesystemException + */ + public function exists(DocumentInterface $document = null): bool + { + if (null !== $document && $document->isLocal() && null !== $mountPath = $document->getMountPath()) { + return $this->documentsStorage->fileExists($mountPath); + } + + return false; + } +} diff --git a/lib/Documents/src/UrlGenerators/AbstractDocumentUrlGenerator.php b/lib/Documents/src/UrlGenerators/AbstractDocumentUrlGenerator.php new file mode 100644 index 00000000..5208d1ad --- /dev/null +++ b/lib/Documents/src/UrlGenerators/AbstractDocumentUrlGenerator.php @@ -0,0 +1,122 @@ +document = $document; + $this->viewOptionsResolver = new ViewOptionsResolver(); + $this->optionCompiler = new OptionsCompiler(); + $this->optionsCacheAdapter = $optionsCacheAdapter; + + $this->setOptions($options); + $this->documentsStorage = $documentsStorage; + $this->urlHelper = $urlHelper; + } + + /** + * @param array $options + * @return $this + * @throws InvalidArgumentException + */ + public function setOptions(array $options = []): static + { + $optionsCacheItem = $this->optionsCacheAdapter->getItem(md5(json_encode($options) ?: '')); + if (!$optionsCacheItem->isHit()) { + $resolvedOptions = $this->viewOptionsResolver->resolve($options); + $optionsCacheItem->set($resolvedOptions); + $this->optionsCacheAdapter->save($optionsCacheItem); + } + + $cachedOptions = $optionsCacheItem->get(); + $this->options = is_array($cachedOptions) ? $cachedOptions : []; + + return $this; + } + + /** + * @return DocumentInterface|null + */ + public function getDocument(): ?DocumentInterface + { + return $this->document; + } + + /** + * @param DocumentInterface $document + * + * @return $this + */ + public function setDocument(DocumentInterface $document): static + { + $this->document = $document; + return $this; + } + + /** + * @param bool $absolute + * + * @return string + */ + public function getUrl(bool $absolute = false): string + { + if (null === $this->document) { + throw new \InvalidArgumentException('Cannot get URL from a NULL document'); + } + + $mountPath = $this->document->getMountPath(); + + if ($this->document->isPrivate()) { + throw new \InvalidArgumentException('Cannot get URL from a private document'); + } + + if (null !== $mountPath && ($this->options['noProcess'] === true || !$this->document->isProcessable())) { + $publicUrl = $this->documentsStorage->publicUrl($mountPath); + if ($absolute && \str_starts_with($publicUrl, '/')) { + return $this->urlHelper->getAbsoluteUrl($publicUrl); + } else { + return $publicUrl; + } + } + + return $this->getProcessedDocumentUrlByArray($absolute); + } + + /** + * @param bool $absolute + * @return string + */ + abstract protected function getProcessedDocumentUrlByArray(bool $absolute = false): string; +} diff --git a/lib/Documents/src/UrlGenerators/DocumentUrlGeneratorInterface.php b/lib/Documents/src/UrlGenerators/DocumentUrlGeneratorInterface.php new file mode 100644 index 00000000..1e1dd5f3 --- /dev/null +++ b/lib/Documents/src/UrlGenerators/DocumentUrlGeneratorInterface.php @@ -0,0 +1,31 @@ +document) { + throw new \BadMethodCallException('Document is null'); + } + if (!key_exists('noProcess', $this->options)) { + throw new \BadMethodCallException('noProcess option is not set'); + } + + if ($this->options['noProcess'] === true || !$this->document->isProcessable()) { + $path = '/files/' . $this->document->getRelativePath(); + + return ($absolute) ? ('http://dummy.test' . $path) : ($path); + } + + $compiler = new OptionsCompiler(); + $compiledOptions = $compiler->compile($this->options); + + if ($absolute) { + return 'http://dummy.test/assets/' . $compiledOptions . '/' . $this->document->getRelativePath(); + } + return '/assets/' . $compiledOptions . '/' . $this->document->getRelativePath(); + } + + public function setDocument(DocumentInterface $document): static + { + $this->document = $document; + return $this; + } + + public function setOptions(array $options = []): static + { + $this->options = $options; + return $this; + } +} diff --git a/lib/Documents/src/UrlGenerators/OptionsCompiler.php b/lib/Documents/src/UrlGenerators/OptionsCompiler.php new file mode 100644 index 00000000..b1864091 --- /dev/null +++ b/lib/Documents/src/UrlGenerators/OptionsCompiler.php @@ -0,0 +1,93 @@ +options = $options; + $shortOptions = []; + + if (null === $this->options['fit'] && $this->options['width'] > 0) { + $shortOptions['w'] = 'w' . (int) $this->options['width']; + } + if (null === $this->options['fit'] && $this->options['height'] > 0) { + $shortOptions['h'] = 'h' . (int) $this->options['height']; + } + if (null !== $this->options['crop']) { + $shortOptions['c'] = 'c' . strip_tags($this->options['crop']); + } + if ($this->options['blur'] > 0) { + $shortOptions['l'] = 'l' . ($this->options['blur']); + } + if (null !== $this->options['fit']) { + $shortOptions['f'] = 'f' . strip_tags($this->options['fit']); + } + if (null !== $this->options['flip']) { + $shortOptions['m'] = 'm' . trim(strip_tags($this->options['flip'])); + } + if ($this->options['rotate'] > 0) { + $shortOptions['r'] = 'r' . ($this->options['rotate']); + } + if ($this->options['sharpen'] > 0) { + $shortOptions['s'] = 's' . ($this->options['sharpen']); + } + if ($this->options['contrast'] > 0) { + $shortOptions['k'] = 'k' . ($this->options['contrast']); + } + if ($this->options['grayscale']) { + $shortOptions['g'] = 'g1'; + } + if ($this->options['quality'] > 0) { + $shortOptions['q'] = 'q' . $this->options['quality']; + } + if (null !== $this->options['background']) { + $shortOptions['b'] = 'b' . strip_tags($this->options['background']); + } + if ($this->options['progressive']) { + $shortOptions['p'] = 'p1'; + } + if ($this->options['interlace']) { + $shortOptions['i'] = 'i1'; + } + + $availablePosition = [ + 'tl' => 'top-left', + 't' => 'top', + 'tr' => 'top-right', + 'l' => 'left', + 'c' => 'center', + 'r' => 'right', + 'bl' => 'bottom-left', + 'b' => 'bottom', + 'br' => 'bottom-right', + ]; + $availablePositionShort = array_flip($availablePosition); + if ( + null !== $this->options['align'] + && isset($availablePositionShort[$this->options['align']]) + ) { + $shortOptions['a'] = 'a' . $availablePositionShort[$this->options['align']]; + } + + return implode('-', $shortOptions); + } +} diff --git a/lib/Documents/src/Viewers/SvgDocumentViewer.php b/lib/Documents/src/Viewers/SvgDocumentViewer.php new file mode 100644 index 00000000..8133ffdc --- /dev/null +++ b/lib/Documents/src/Viewers/SvgDocumentViewer.php @@ -0,0 +1,184 @@ +imageUrl = $imageUrl; + $this->attributes = $attributes; + $this->asObject = $asObject; + $this->documentsStorage = $documentsStorage; + $this->document = $document; + } + + /** + * Get SVG string to be used inside HTML content. + * + * @return string + * @throws FilesystemException + */ + public function getContent(): string + { + if (false === $this->asObject) { + return $this->getInlineSvg(); + } else { + return $this->getObjectSvg(); + } + } + + /** + * @return array + */ + protected function getAllowedAttributes(): array + { + $attributes = []; + foreach ($this->attributes as $key => $value) { + if (in_array($key, static::$allowedAttributes)) { + if ($key === 'identifier') { + $attributes['id'] = $value; + } else { + $attributes[$key] = $value; + } + } + } + return $attributes; + } + + /** + * @return string + * @throws FilesystemException + */ + protected function getInlineSvg(): string + { + $mountPath = $this->document->getMountPath(); + + if (null === $mountPath) { + throw new FileNotFoundException('SVG document has no file'); + } + + if (!$this->documentsStorage->fileExists($mountPath)) { + throw new FileNotFoundException('SVG file does not exist: ' . $mountPath); + } + // Create a new sanitizer instance + $sanitizer = new Sanitizer(); + $sanitizer->minify(true); + + // Load the dirty svg + $dirtySVG = $this->documentsStorage->read($mountPath); + /** + * @var string|false $cleanSVG + */ + $cleanSVG = $sanitizer->sanitize($dirtySVG); + if (false !== $cleanSVG) { + // Pass it to the sanitizer and get it back clean + return $this->injectAttributes($cleanSVG); + } + return $dirtySVG; + } + + /** + * @param string $svg + * @return string + * @throws \Exception + */ + protected function injectAttributes(string $svg): string + { + $attributes = $this->getAllowedAttributes(); + if (count($attributes) > 0) { + $xml = new \SimpleXMLElement($svg); + $xml->registerXPathNamespace('svg', 'http://www.w3.org/2000/svg'); + $xml->registerXPathNamespace('xlink', 'http://www.w3.org/1999/xlink'); + $xml->registerXPathNamespace('a', 'http://ns.adobe.com/AdobeSVGViewerExtensions/3.0/'); + $xml->registerXPathNamespace('ns1', 'http://ns.adobe.com/Flows/1.0/'); + $xml->registerXPathNamespace('ns0', 'http://ns.adobe.com/SaveForWeb/1.0/'); + $xml->registerXPathNamespace('ns', 'http://ns.adobe.com/Variables/1.0/'); + $xml->registerXPathNamespace('i', 'http://ns.adobe.com/AdobeIllustrator/10.0/'); + $xml->registerXPathNamespace('x', 'http://ns.adobe.com/Extensibility/1.0/'); + $xml->registerXPathNamespace('dc', 'http://purl.org/dc/elements/1.1/'); + $xml->registerXPathNamespace('cc', 'http://creativecommons.org/ns#'); + $xml->registerXPathNamespace('rdf', 'http://www.w3.org/1999/02/22-rdf-syntax-ns#'); + $xml->registerXPathNamespace('sodipodi', 'http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd'); + $xml->registerXPathNamespace('inkscape', 'http://www.inkscape.org/namespaces/inkscape'); + + foreach ($attributes as $key => $value) { + if (isset($xml->attributes()->$key)) { + $xml->attributes()->$key = (string) $value; + } else { + $xml->addAttribute($key, (string) $value); + } + } + $svg = $xml->asXML(); + } + if (false === $svg) { + throw new \RuntimeException('Cannot inject attributes into SVG'); + } + + return \preg_replace('#^\<\?xml[^\?]+\?\>#', '', (string) $svg) ?? ''; + } + + /** + * @return string + * @deprecated Use SvgRenderer to render HTML object. + */ + protected function getObjectSvg(): string + { + $mountPath = $this->document->getMountPath(); + + if (null === $mountPath) { + throw new FileNotFoundException('SVG document has no file'); + } + + $attributes = $this->getAllowedAttributes(); + $attributes['type'] = 'image/svg+xml'; + $attributes['data'] = $this->documentsStorage->publicUrl($mountPath); + + if (isset($attributes['alt'])) { + unset($attributes['alt']); + } + + $attrs = []; + foreach ($attributes as $key => $value) { + $attrs[] = $key . '="' . htmlspecialchars($value) . '"'; + } + + return ''; + } +} diff --git a/lib/Documents/tests/Roadiz/Document/Renderer/AudioRenderer.php b/lib/Documents/tests/Roadiz/Document/Renderer/AudioRenderer.php new file mode 100644 index 00000000..5dedc95f --- /dev/null +++ b/lib/Documents/tests/Roadiz/Document/Renderer/AudioRenderer.php @@ -0,0 +1,207 @@ +setFilename('file.mp3'); + $mockValidDocument->setMimeType('audio/mpeg'); + + /** @var DocumentInterface $mockInvalidDocument */ + $mockInvalidDocument = new \mock\RZ\Roadiz\Documents\Models\SimpleDocument(); + $mockInvalidDocument->setFilename('file.jpg'); + $mockInvalidDocument->setMimeType('image/jpeg'); + + $this + ->given($renderer = $this->newTestedInstance( + $this->getFilesystemOperator(), + $this->getDocumentFinder(), + $this->getEnvironment(), + $this->getUrlGenerator() + )) + ->then + ->string($mockValidDocument->getMimeType()) + ->isEqualTo('audio/mpeg') + ->boolean($renderer->supports($mockValidDocument, [])) + ->isEqualTo(true) + ->string($mockInvalidDocument->getMimeType()) + ->isEqualTo('image/jpeg') + ->boolean($renderer->supports($mockInvalidDocument, [])) + ->isEqualTo(false); + } + + public function testRender() + { + /** @var DocumentInterface $mockDocument */ + $mockDocument = new \mock\RZ\Roadiz\Documents\Models\SimpleDocument(); + $mockDocument->setFilename('file.mp3'); + $mockDocument->setFolder('folder'); + $mockDocument->setMimeType('audio/mpeg'); + + /** @var DocumentInterface $mockDocument2 */ + $mockDocument2 = new \mock\RZ\Roadiz\Documents\Models\SimpleDocument(); + $mockDocument2->setFilename('file2.mp3'); + $mockDocument2->setFolder('folder'); + $mockDocument2->setMimeType('audio/mpeg'); + + $this + ->given($renderer = $this->newTestedInstance( + $this->getFilesystemOperator(), + $this->getDocumentFinder(), + $this->getEnvironment(), + $this->getUrlGenerator() + )) + ->then + ->string($mockDocument->getMimeType()) + ->isEqualTo('audio/mpeg') + ->string($this->htmlTidy($renderer->render($mockDocument, []))) + ->isEqualTo($this->htmlTidy(<< + + +

Your browser does not support native audio.

+ +EOT + )) + ->string($this->htmlTidy($renderer->render($mockDocument2, []))) + ->isEqualTo($this->htmlTidy(<< + +

Your browser does not support native audio.

+ +EOT + )) + ->string($this->htmlTidy($renderer->render($mockDocument, [ + 'controls' => true, + 'loop' => true, + 'autoplay' => true, + ]))) + ->isEqualTo($this->htmlTidy(<< + + +

Your browser does not support native audio.

+ +EOT + )) + ->string($this->htmlTidy($renderer->render($mockDocument, [ + 'controls' => false + ]))) + ->isEqualTo($this->htmlTidy(<< + + +

Your browser does not support native audio.

+ +EOT + )) + ; + } + + /** + * @return DocumentFinderInterface + */ + private function getDocumentFinder(): DocumentFinderInterface + { + $finder = new ArrayDocumentFinder(); + + $finder->addDocument( + (new \mock\RZ\Roadiz\Documents\Models\SimpleDocument()) + ->setFilename('file.mp3') + ->setFolder('folder') + ->setMimeType('audio/mpeg') + ); + $finder->addDocument( + (new \mock\RZ\Roadiz\Documents\Models\SimpleDocument()) + ->setFilename('file.ogg') + ->setFolder('folder') + ->setMimeType('audio/ogg') + ); + $finder->addDocument( + (new \mock\RZ\Roadiz\Documents\Models\SimpleDocument()) + ->setFilename('file2.mp3') + ->setFolder('folder') + ->setMimeType('audio/mpeg') + ); + + return $finder; + } + + /** + * @return DocumentUrlGeneratorInterface + */ + private function getUrlGenerator(): DocumentUrlGeneratorInterface + { + return new \mock\RZ\Roadiz\Documents\UrlGenerators\DummyDocumentUrlGenerator(); + } + + private function getFilesystemOperator(): FilesystemOperator + { + return new MountManager([ + 'public' => new Filesystem( + new LocalFilesystemAdapter(dirname(__DIR__) . '/../../../files/'), + publicUrlGenerator: new class() implements PublicUrlGenerator + { + public function publicUrl(string $path, Config $config): string + { + return '/files/' . $path; + } + } + ), + 'private' => new Filesystem( + new LocalFilesystemAdapter(dirname(__DIR__) . '/../../../files/'), + publicUrlGenerator: new class() implements PublicUrlGenerator + { + public function publicUrl(string $path, Config $config): string + { + return '/files/' . $path; + } + } + ) + ]); + } + + private function getEnvironment(): Environment + { + $loader = new FilesystemLoader([ + dirname(__DIR__) . '/../../../src/Resources/views' + ]); + return new Environment($loader, [ + 'autoescape' => false + ]); + } + + /** + * @param string $body + * + * @return string + */ + private function htmlTidy(string $body): string + { + $body = preg_replace('#[\n\r\t\s]{2,}#', ' ', $body); + $body = str_replace("/", '/', $body); + $body = html_entity_decode($body); + return preg_replace('#\>[\n\r\t\s]+\<#', '><', $body); + } +} diff --git a/lib/Documents/tests/Roadiz/Document/Renderer/ChainRenderer.php b/lib/Documents/tests/Roadiz/Document/Renderer/ChainRenderer.php new file mode 100644 index 00000000..1e2bd38d --- /dev/null +++ b/lib/Documents/tests/Roadiz/Document/Renderer/ChainRenderer.php @@ -0,0 +1,169 @@ +setFilename('file.svg'); + $mockSvgDocument->setFolder('folder'); + $mockSvgDocument->setMimeType('image/svg'); + + /** @var DocumentInterface $mockPdfDocument */ + $mockPdfDocument = new \mock\RZ\Roadiz\Documents\Models\SimpleDocument(); + $mockPdfDocument->setFilename('file.pdf'); + $mockPdfDocument->setFolder('folder'); + $mockPdfDocument->setMimeType('application/pdf'); + + /** @var DocumentInterface $mockDocumentYoutube */ + $mockDocumentYoutube = new \mock\RZ\Roadiz\Documents\Models\SimpleDocument(); + $mockDocumentYoutube->setFilename('poster.jpg'); + $mockDocumentYoutube->setEmbedId('xxxxxxx'); + $mockDocumentYoutube->setEmbedPlatform('youtube'); + $mockDocumentYoutube->setMimeType('image/jpeg'); + + /** @var DocumentInterface $mockPictureDocument */ + $mockPictureDocument = new \mock\RZ\Roadiz\Documents\Models\SimpleDocument(); + $mockPictureDocument->setFilename('file.jpg'); + $mockPictureDocument->setFolder('folder'); + $mockPictureDocument->setMimeType('image/jpeg'); + + $renderers = [ + new Renderer\InlineSvgRenderer($this->getFilesystemOperator()), + new Renderer\SvgRenderer($this->getFilesystemOperator()), + new Renderer\PdfRenderer($this->getFilesystemOperator(), $this->getEnvironment(), $this->getUrlGenerator()), + new Renderer\ImageRenderer($this->getFilesystemOperator(), $this->getEmbedFinderFactory(), $this->getEnvironment(), $this->getUrlGenerator()), + new Renderer\PictureRenderer($this->getFilesystemOperator(), $this->getEmbedFinderFactory(), $this->getEnvironment(), $this->getUrlGenerator()), + new Renderer\EmbedRenderer($this->getEmbedFinderFactory()), + ]; + + $this + ->given($renderer = $this->newTestedInstance($renderers)) + ->then + ->string($this->htmlTidy($renderer->render($mockPdfDocument, [ + 'embed' => true + ]))) + ->isEqualTo('

Your browser does not support PDF native viewer.

') + ->string($this->htmlTidy($renderer->render($mockPdfDocument, ['absolute' => true, 'embed' => true]))) + ->isEqualTo('

Your browser does not support PDF native viewer.

') + ->string($this->htmlTidy($renderer->render($mockSvgDocument, []))) + ->isEqualTo($this->htmlTidy(<< +EOT + )) + ->string($this->htmlTidy($renderer->render($mockSvgDocument, ['inline' => true]))) + ->isEqualTo($this->htmlTidy(<< + + +EOT + )) + ->boolean($mockDocumentYoutube->isEmbed()) + ->isEqualTo(true) + ->string($this->htmlTidy($renderer->render($mockDocumentYoutube, ['embed' => true]))) + ->isEqualTo($this->htmlTidy(<< +EOT + )) + ->string($this->htmlTidy($renderer->render($mockPictureDocument, [ + 'width' => 300, + 'picture' => true + ]))) + ->isEqualTo($this->htmlTidy(<< + + +file.jpg + +EOT + )) + ; + } + + private function htmlTidy(string $body): string + { + $body = preg_replace('#[\n\r\t\s]{2,}#', ' ', $body); + $body = str_replace("/", '/', $body); + $body = html_entity_decode($body); + return preg_replace('#\>[\n\r\t\s]+\<#', '><', $body); + } + + /** + * @return DocumentUrlGeneratorInterface + */ + private function getUrlGenerator(): DocumentUrlGeneratorInterface + { + return new \mock\RZ\Roadiz\Documents\UrlGenerators\DummyDocumentUrlGenerator(); + } + + private function getFilesystemOperator(): FilesystemOperator + { + return new MountManager([ + 'public' => new Filesystem( + new LocalFilesystemAdapter(dirname(__DIR__) . '/../../../files/'), + publicUrlGenerator: new class() implements PublicUrlGenerator + { + public function publicUrl(string $path, Config $config): string + { + return '/files/' . $path; + } + } + ), + 'private' => new Filesystem( + new LocalFilesystemAdapter(dirname(__DIR__) . '/../../../files/'), + publicUrlGenerator: new class() implements PublicUrlGenerator + { + public function publicUrl(string $path, Config $config): string + { + return '/files/' . $path; + } + } + ) + ]); + } + + private function getEnvironment(): Environment + { + $loader = new FilesystemLoader([ + dirname(__DIR__) . '/../../../src/Resources/views' + ]); + return new Environment($loader, [ + 'autoescape' => false + ]); + } + + /** + * @return EmbedFinderFactory + */ + private function getEmbedFinderFactory(): EmbedFinderFactory + { + return new EmbedFinderFactory([ + 'youtube' => \mock\RZ\Roadiz\Documents\MediaFinders\AbstractYoutubeEmbedFinder::class, + 'vimeo' => \mock\RZ\Roadiz\Documents\MediaFinders\AbstractVimeoEmbedFinder::class, + 'dailymotion' => \mock\RZ\Roadiz\Documents\MediaFinders\AbstractDailymotionEmbedFinder::class, + 'soundcloud' => \mock\RZ\Roadiz\Documents\MediaFinders\AbstractSoundcloudEmbedFinder::class, + ]); + } +} diff --git a/lib/Documents/tests/Roadiz/Document/Renderer/EmbedRenderer.php b/lib/Documents/tests/Roadiz/Document/Renderer/EmbedRenderer.php new file mode 100644 index 00000000..aca35b64 --- /dev/null +++ b/lib/Documents/tests/Roadiz/Document/Renderer/EmbedRenderer.php @@ -0,0 +1,180 @@ +setFilename('poster.jpg'); + $mockValidDocument->setEmbedId('xxxxxxx'); + $mockValidDocument->setEmbedPlatform('youtube'); + $mockValidDocument->setMimeType('image/jpeg'); + + /** @var DocumentInterface $mockInvalidDocument */ + $mockInvalidDocument = new \mock\RZ\Roadiz\Documents\Models\SimpleDocument(); + $mockInvalidDocument->setFilename('file.jpg'); + $mockInvalidDocument->setMimeType('image/jpeg'); + + /** @var DocumentInterface $mockExternalInvalidDocument */ + $mockExternalInvalidDocument = new \mock\RZ\Roadiz\Documents\Models\SimpleDocument(); + $mockExternalInvalidDocument->setFilename('file.jpg'); + $mockExternalInvalidDocument->setMimeType('image/jpeg'); + $mockExternalInvalidDocument->setEmbedId('xxxxx'); + $mockExternalInvalidDocument->setEmbedPlatform('getty'); + + $this + ->given($renderer = $this->newTestedInstance( + $this->getEmbedFinderFactory() + )) + ->then + ->boolean($mockValidDocument->isEmbed()) + ->isEqualTo(true) + ->boolean($renderer->supports($mockValidDocument, [])) + ->isEqualTo(false) + ->boolean($renderer->supports($mockValidDocument, ['embed' => true])) + ->isEqualTo(true) + ->boolean($renderer->supports($mockExternalInvalidDocument, ['embed' => true])) + ->isEqualTo(false) + ->boolean($mockInvalidDocument->isEmbed()) + ->isEqualTo(false) + ->boolean($renderer->supports($mockInvalidDocument, [])) + ->isEqualTo(false) + ->boolean($renderer->supports($mockInvalidDocument, ['embed' => true])) + ->isEqualTo(false) + ; + } + + public function testRender() + { + /** @var DocumentInterface $mockDocumentYoutube */ + $mockDocumentYoutube = new \mock\RZ\Roadiz\Documents\Models\SimpleDocument(); + $mockDocumentYoutube->setFilename('poster.jpg'); + $mockDocumentYoutube->setEmbedId('xxxxxxx'); + $mockDocumentYoutube->setEmbedPlatform('youtube'); + $mockDocumentYoutube->setMimeType('image/jpeg'); + + /** @var DocumentInterface $mockDocumentVimeo */ + $mockDocumentVimeo = new \mock\RZ\Roadiz\Documents\Models\SimpleDocument(); + $mockDocumentVimeo->setFilename('poster.jpg'); + $mockDocumentVimeo->setEmbedId('0000000'); + $mockDocumentVimeo->setEmbedPlatform('vimeo'); + $mockDocumentVimeo->setMimeType('image/jpeg'); + + $this + ->given($renderer = $this->newTestedInstance( + $this->getEmbedFinderFactory() + )) + ->then + ->boolean($mockDocumentYoutube->isEmbed()) + ->isEqualTo(true) + ->string($renderer->render($mockDocumentYoutube, ['embed' => true])) + ->isEqualTo($this->htmlTidy(<< +EOT + )) + ->string($renderer->render($mockDocumentYoutube, [ + 'embed' => true, + 'loading' => 'lazy' + ])) + ->isEqualTo($this->htmlTidy(<< +EOT + )) + ->string($renderer->render($mockDocumentYoutube, [ + 'embed' => true, + 'width' => 500 + // height is auto calculated based on 16/10 ratio + ])) + ->isEqualTo($this->htmlTidy(<< +EOT + )) + ->string($renderer->render($mockDocumentYoutube, [ + 'embed' => true, + 'width' => 500, + 'height' => 500 + ])) + ->isEqualTo($this->htmlTidy(<< +EOT + )) + ->string($renderer->render($mockDocumentYoutube, [ + 'embed' => true, + 'autoplay' => true, + ])) + ->isEqualTo($this->htmlTidy(<< +EOT + )) + ->boolean($mockDocumentVimeo->isEmbed()) + ->isEqualTo(true) + ->string($renderer->render($mockDocumentVimeo, ['embed' => true])) + ->isEqualTo($this->htmlTidy(<< +EOT + )) + ->string($renderer->render($mockDocumentVimeo, [ + 'embed' => true, + 'autoplay' => true, + 'automute' => true, + ])) + ->isEqualTo($this->htmlTidy(<< +EOT + )) + ->string($renderer->render($mockDocumentVimeo, [ + 'embed' => true, + 'autoplay' => true, + 'background' => "1", // Hack background conflict option with background color + ])) + ->isEqualTo($this->htmlTidy(<< +EOT + )) + ; + } + + /** + * @return EmbedFinderFactory + */ + private function getEmbedFinderFactory(): EmbedFinderFactory + { + return new EmbedFinderFactory([ + 'youtube' => \mock\RZ\Roadiz\Documents\MediaFinders\AbstractYoutubeEmbedFinder::class, + 'vimeo' => \mock\RZ\Roadiz\Documents\MediaFinders\AbstractVimeoEmbedFinder::class, + 'dailymotion' => \mock\RZ\Roadiz\Documents\MediaFinders\AbstractDailymotionEmbedFinder::class, + 'soundcloud' => \mock\RZ\Roadiz\Documents\MediaFinders\AbstractSoundcloudEmbedFinder::class, + ]); + } + + private function htmlTidy(string $body): string + { + $body = preg_replace('#[\n\r\t\s]{2,}#', ' ', $body); + return preg_replace('#\>[\n\r\t\s]+\<#', '><', $body); + } +} diff --git a/lib/Documents/tests/Roadiz/Document/Renderer/ImageRenderer.php b/lib/Documents/tests/Roadiz/Document/Renderer/ImageRenderer.php new file mode 100644 index 00000000..91800bb7 --- /dev/null +++ b/lib/Documents/tests/Roadiz/Document/Renderer/ImageRenderer.php @@ -0,0 +1,437 @@ +setFilename('file.jpg'); + $mockValidDocument->setMimeType('image/jpeg'); + + /** @var DocumentInterface $mockExternalValidDocument */ + $mockExternalValidDocument = new \mock\RZ\Roadiz\Documents\Models\SimpleDocument(); + $mockExternalValidDocument->setFilename('file.jpg'); + $mockExternalValidDocument->setMimeType('image/jpeg'); + $mockExternalValidDocument->setEmbedId('xxxxx'); + $mockExternalValidDocument->setEmbedPlatform('getty'); + + /** @var DocumentInterface $mockInvalidDocument */ + $mockInvalidDocument = new \mock\RZ\Roadiz\Documents\Models\SimpleDocument(); + $mockInvalidDocument->setFilename('file.psd'); + $mockInvalidDocument->setMimeType('image/vnd.adobe.photoshop'); + + $this + ->given($renderer = $this->newTestedInstance( + $this->getFilesystemOperator(), + $this->getEmbedFinderFactory(), + $this->getEnvironment(), + $this->getUrlGenerator() + )) + ->then + ->string($mockValidDocument->getMimeType()) + ->isEqualTo('image/jpeg') + ->boolean($renderer->supports($mockValidDocument, [])) + ->isEqualTo(true) + ->boolean($renderer->supports($mockValidDocument, ['embed' => true])) + ->isEqualTo(true) + ->boolean($renderer->supports($mockExternalValidDocument, ['embed' => true])) + ->isEqualTo(true) + ->boolean($renderer->supports($mockValidDocument, ['picture' => true])) + ->isEqualTo(false) + ->string($mockInvalidDocument->getMimeType()) + ->isEqualTo('image/vnd.adobe.photoshop') + ->boolean($renderer->supports($mockInvalidDocument, [])) + ->isEqualTo(false); + } + + public function testRender() + { + /** @var DocumentInterface $mockDocument */ + $mockDocument = new \mock\RZ\Roadiz\Documents\Models\SimpleDocument(); + $mockDocument->setFilename('file.jpg'); + $mockDocument->setFolder('folder'); + $mockDocument->setMimeType('image/jpeg'); + + $this + ->given($renderer = $this->newTestedInstance( + $this->getFilesystemOperator(), + $this->getEmbedFinderFactory(), + $this->getEnvironment(), + $this->getUrlGenerator() + )) + ->then + ->string($this->htmlTidy($renderer->render($mockDocument, ['noProcess' => true]))) + ->isEqualTo($this->htmlTidy(<< +EOT + )) + ->string($this->htmlTidy($renderer->render($mockDocument, ['absolute' => true, 'noProcess' => true]))) + ->isEqualTo($this->htmlTidy(<< +EOT + )) + ->string($this->htmlTidy($renderer->render($mockDocument, [ + 'width' => 300, + 'absolute' => true + ]))) + ->isEqualTo($this->htmlTidy(<< +EOT + )) + ->string($this->htmlTidy($renderer->render($mockDocument, [ + 'width' => 300, + 'class' => 'awesome-image responsive', + 'absolute' => true + ]))) + ->isEqualTo($this->htmlTidy(<< +EOT + )) + ->string($this->htmlTidy($renderer->render($mockDocument, [ + 'width' => 300, + 'lazyload' => true + ]))) + ->contains('noscript') + ->isEqualTo($this->htmlTidy(<< + +EOT + )) + ->string($this->htmlTidy($renderer->render($mockDocument, [ + 'width' => 300, + 'lazyload' => true, + 'fallback' => 'https://test.test/fallback.png' + ]))) + ->contains('noscript') + ->isEqualTo($this->htmlTidy(<< + +EOT + )) + ->string($this->htmlTidy($renderer->render($mockDocument, [ + 'width' => 300, + 'fallback' => 'https://test.test/fallback.png' + ]))) + ->isEqualTo($this->htmlTidy(<< +EOT + )) + ->string($this->htmlTidy($renderer->render($mockDocument, [ + 'fit' => '600x400', + 'quality' => 70 + ]))) + ->isEqualTo($this->htmlTidy(<< +EOT + )) + ->string($this->htmlTidy($renderer->render($mockDocument, [ + 'width' => 300, + 'lazyload' => true, + 'class' => 'awesome-image responsive', + ]))) + ->contains('noscript') + ->isEqualTo($this->htmlTidy(<< + +EOT + )) + ->string($this->htmlTidy($renderer->render($mockDocument, [ + 'width' => 300, + 'srcset' => [[ + 'format' => [ + 'width' => 300 + ], + 'rule' => '1x' + ],[ + 'format' => [ + 'width' => 600 + ], + 'rule' => '2x' + ]] + ]))) + ->isEqualTo($this->htmlTidy(<< +EOT + )) + ->string($this->htmlTidy($renderer->render($mockDocument, [ + 'width' => 300, + 'srcset' => [[ + 'format' => [ + 'width' => 300 + ], + 'rule' => '1x' + ],[ + 'format' => [ + 'width' => 600 + ], + 'rule' => '2x' + ]], + 'sizes' => [ + '(max-width: 767px) 300px', + '(min-width: 768px) 400px' + ] + ]))) + ->isEqualTo($this->htmlTidy(<< +EOT + )) + ->string($this->htmlTidy($renderer->render($mockDocument, [ + 'fit' => '600x400', + 'srcset' => [[ + 'format' => [ + 'fit' => '600x400', + ], + 'rule' => '1x' + ],[ + 'format' => [ + 'fit' => '1200x800', + ], + 'rule' => '2x' + ]], + 'sizes' => [ + '(max-width: 767px) 300px', + '(min-width: 768px) 400px' + ] + ]))) + ->isEqualTo($this->htmlTidy(<< +EOT + )) + + ->string($this->htmlTidy($renderer->render($mockDocument, [ + 'fit' => '600x400', + 'loading' => 'lazy', + 'srcset' => [[ + 'format' => [ + 'fit' => '600x400', + ], + 'rule' => '1x' + ],[ + 'format' => [ + 'fit' => '1200x800', + ], + 'rule' => '2x' + ]], + 'sizes' => [ + '(max-width: 767px) 300px', + '(min-width: 768px) 400px' + ] + ]))) + ->isEqualTo($this->htmlTidy(<< +EOT + )) + ->string($this->htmlTidy($renderer->render($mockDocument, [ + 'fit' => '600x400', + 'lazyload' => true, + 'srcset' => [[ + 'format' => [ + 'fit' => '600x400', + ], + 'rule' => '1x' + ],[ + 'format' => [ + 'fit' => '1200x800', + ], + 'rule' => '2x' + ]], + 'sizes' => [ + '(max-width: 767px) 300px', + '(min-width: 768px) 400px' + ] + ]))) + ->contains('noscript') + ->isEqualTo($this->htmlTidy(<< + +EOT + )) + ->string($this->htmlTidy($renderer->render($mockDocument, [ + 'fit' => '600x400', + 'lazyload' => true, + 'loading' => 'lazy', + 'srcset' => [[ + 'format' => [ + 'fit' => '600x400', + ], + 'rule' => '1x' + ],[ + 'format' => [ + 'fit' => '1200x800', + ], + 'rule' => '2x' + ]], + 'sizes' => [ + '(max-width: 767px) 300px', + '(min-width: 768px) 400px' + ] + ]))) + ->contains('noscript') + ->isEqualTo($this->htmlTidy(<< + +EOT + )) + ; + } + + /** + * @return \RZ\Roadiz\Documents\UrlGenerators\DocumentUrlGeneratorInterface + */ + private function getUrlGenerator(): DocumentUrlGeneratorInterface + { + return new \mock\RZ\Roadiz\Documents\UrlGenerators\DummyDocumentUrlGenerator(); + } + + private function getFilesystemOperator(): FilesystemOperator + { + return new MountManager([ + 'public' => new Filesystem( + new LocalFilesystemAdapter(dirname(__DIR__) . '/../../../files/'), + publicUrlGenerator: new class() implements PublicUrlGenerator + { + public function publicUrl(string $path, Config $config): string + { + return '/files/' . $path; + } + } + ), + 'private' => new Filesystem( + new LocalFilesystemAdapter(dirname(__DIR__) . '/../../../files/'), + publicUrlGenerator: new class() implements PublicUrlGenerator + { + public function publicUrl(string $path, Config $config): string + { + return '/files/' . $path; + } + } + ) + ]); + } + + private function htmlTidy(string $body): string + { + $body = preg_replace('#[\n\r\s]{2,}#', ' ', $body); + $body = str_replace("/", '/', $body); + $body = html_entity_decode($body); + return preg_replace('#\>[\n\r\s]+\<#', '><', $body); + } + + private function getEnvironment(): Environment + { + $loader = new FilesystemLoader([ + dirname(__DIR__) . '/../../../src/Resources/views' + ]); + return new Environment($loader, [ + 'autoescape' => false, + 'debug' => true + ]); + } + + /** + * @return \RZ\Roadiz\Documents\MediaFinders\EmbedFinderFactory + */ + private function getEmbedFinderFactory(): EmbedFinderFactory + { + return new EmbedFinderFactory([ + 'youtube' => \mock\RZ\Roadiz\Documents\MediaFinders\AbstractYoutubeEmbedFinder::class, + 'vimeo' => \mock\RZ\Roadiz\Documents\MediaFinders\AbstractVimeoEmbedFinder::class, + 'dailymotion' => \mock\RZ\Roadiz\Documents\MediaFinders\AbstractDailymotionEmbedFinder::class, + 'soundcloud' => \mock\RZ\Roadiz\Documents\MediaFinders\AbstractSoundcloudEmbedFinder::class, + ]); + } +} diff --git a/lib/Documents/tests/Roadiz/Document/Renderer/InlineSvgRenderer.php b/lib/Documents/tests/Roadiz/Document/Renderer/InlineSvgRenderer.php new file mode 100644 index 00000000..0eb6178c --- /dev/null +++ b/lib/Documents/tests/Roadiz/Document/Renderer/InlineSvgRenderer.php @@ -0,0 +1,100 @@ +setFilename('file.svg'); + $mockValidDocument->setMimeType('image/svg'); + + $mockInvalidDocument->setFilename('file.jpg'); + $mockInvalidDocument->setMimeType('image/jpeg'); + + $this + ->given($renderer = $this->newTestedInstance($this->getFilesystemOperator())) + ->then + ->string($mockValidDocument->getMimeType()) + ->isEqualTo('image/svg') + ->boolean($renderer->supports($mockValidDocument, ['inline' => false])) + ->isEqualTo(false) + ->boolean($renderer->supports($mockValidDocument, ['inline' => true])) + ->isEqualTo(true) + ->string($mockInvalidDocument->getMimeType()) + ->isEqualTo('image/jpeg') + ->boolean($renderer->supports($mockInvalidDocument, [])) + ->isEqualTo(false); + } + + public function testRender() + { + /** @var DocumentInterface $mockDocument */ + $mockDocument = new \mock\RZ\Roadiz\Documents\Models\SimpleDocument(); + + $mockDocument->setFilename('file.svg'); + $mockDocument->setFolder('folder'); + $mockDocument->setMimeType('image/svg'); + + $this + ->given($renderer = $this->newTestedInstance($this->getFilesystemOperator())) + ->then + ->string($mockDocument->getMimeType()) + ->isEqualTo('image/svg') + ->string($renderer->render($mockDocument, ['inline' => true])) + ->isEqualTo($this->htmlTidy(<< + + +EOT +)); + } + + private function htmlTidy(string $body): string + { + return preg_replace('#\>[\n\r\s]+\<#', '><', $body); + } + + private function getFilesystemOperator(): FilesystemOperator + { + return new MountManager([ + 'public' => new Filesystem( + new LocalFilesystemAdapter(dirname(__DIR__) . '/../../../files/'), + publicUrlGenerator: new class() implements PublicUrlGenerator + { + public function publicUrl(string $path, Config $config): string + { + return '/files/' . $path; + } + } + ), + 'private' => new Filesystem( + new LocalFilesystemAdapter(dirname(__DIR__) . '/../../../files/'), + publicUrlGenerator: new class() implements PublicUrlGenerator + { + public function publicUrl(string $path, Config $config): string + { + return '/files/' . $path; + } + } + ) + ]); + } +} diff --git a/lib/Documents/tests/Roadiz/Document/Renderer/PdfRenderer.php b/lib/Documents/tests/Roadiz/Document/Renderer/PdfRenderer.php new file mode 100644 index 00000000..14f42471 --- /dev/null +++ b/lib/Documents/tests/Roadiz/Document/Renderer/PdfRenderer.php @@ -0,0 +1,129 @@ +setFilename('file.pdf'); + $mockValidDocument->setMimeType('application/pdf'); + + /** @var DocumentInterface $mockInvalidDocument */ + $mockInvalidDocument = new \mock\RZ\Roadiz\Documents\Models\SimpleDocument(); + $mockInvalidDocument->setFilename('file.jpg'); + $mockInvalidDocument->setMimeType('image/jpeg'); + + $this + ->given($renderer = $this->newTestedInstance( + $this->getFilesystemOperator(), + $this->getEnvironment(), + $this->getUrlGenerator() + )) + ->then + ->string($mockValidDocument->getMimeType()) + ->isEqualTo('application/pdf') + ->boolean($renderer->supports($mockValidDocument, [ + 'embed' => true + ])) + ->isEqualTo(true) + ->string($mockInvalidDocument->getMimeType()) + ->isEqualTo('image/jpeg') + ->boolean($renderer->supports($mockInvalidDocument, [])) + ->isEqualTo(false); + } + + public function testRender() + { + /** @var DocumentInterface $mockDocument */ + $mockDocument = new \mock\RZ\Roadiz\Documents\Models\SimpleDocument(); + $mockDocument->setFilename('file.pdf'); + $mockDocument->setFolder('folder'); + $mockDocument->setMimeType('application/pdf'); + + $this + ->given($renderer = $this->newTestedInstance( + $this->getFilesystemOperator(), + $this->getEnvironment(), + $this->getUrlGenerator() + )) + ->then + ->string($mockDocument->getMimeType()) + ->isEqualTo('application/pdf') + ->string($this->htmlTidy($renderer->render($mockDocument, [ + 'embed' => true + ]))) + ->isEqualTo($this->htmlTidy( + '

Your browser does not support PDF native viewer.

' + )); + } + + /** + * @return DocumentUrlGeneratorInterface + */ + private function getUrlGenerator(): DocumentUrlGeneratorInterface + { + return new \mock\RZ\Roadiz\Documents\UrlGenerators\DummyDocumentUrlGenerator(); + } + + private function getFilesystemOperator(): FilesystemOperator + { + return new MountManager([ + 'public' => new Filesystem( + new LocalFilesystemAdapter(dirname(__DIR__) . '/../../../files/'), + publicUrlGenerator: new class() implements PublicUrlGenerator + { + public function publicUrl(string $path, Config $config): string + { + return '/files/' . $path; + } + } + ), + 'private' => new Filesystem( + new LocalFilesystemAdapter(dirname(__DIR__) . '/../../../files/'), + publicUrlGenerator: new class() implements PublicUrlGenerator + { + public function publicUrl(string $path, Config $config): string + { + return '/files/' . $path; + } + } + ) + ]); + } + + private function getEnvironment(): Environment + { + $loader = new FilesystemLoader([ + dirname(__DIR__) . '/../../../src/Resources/views' + ]); + return new Environment($loader, [ + 'autoescape' => false + ]); + } + + private function htmlTidy(string $body): string + { + $body = preg_replace('#[\n\r\s]{2,}#', ' ', $body); + $body = str_replace("/", '/', $body); + $body = html_entity_decode($body); + return preg_replace('#\>[\n\r\s]+\<#', '><', $body); + } +} diff --git a/lib/Documents/tests/Roadiz/Document/Renderer/PictureRenderer.php b/lib/Documents/tests/Roadiz/Document/Renderer/PictureRenderer.php new file mode 100644 index 00000000..dd1ff0db --- /dev/null +++ b/lib/Documents/tests/Roadiz/Document/Renderer/PictureRenderer.php @@ -0,0 +1,921 @@ +setFilename('file.jpg'); + $mockExternalValidDocument->setMimeType('image/jpeg'); + $mockExternalValidDocument->setEmbedId('xxxxx'); + $mockExternalValidDocument->setEmbedPlatform('getty'); + + /** @var DocumentInterface $mockYoutubeDocument */ + $mockYoutubeDocument = new \mock\RZ\Roadiz\Documents\Models\SimpleDocument(); + $mockYoutubeDocument->setFilename('file.jpg'); + $mockYoutubeDocument->setMimeType('image/jpeg'); + $mockYoutubeDocument->setEmbedId('xxxxx'); + $mockYoutubeDocument->setEmbedPlatform('youtube'); + + /** @var DocumentInterface $mockValidDocument */ + $mockValidDocument = new \mock\RZ\Roadiz\Documents\Models\SimpleDocument(); + $mockValidDocument->setFilename('file.jpg'); + $mockValidDocument->setMimeType('image/jpeg'); + + $this + ->given($renderer = $this->newTestedInstance( + $this->getFilesystemOperator(), + $this->getEmbedFinderFactory(), + $this->getEnvironment(), + $this->getUrlGenerator() + )) + ->then + ->boolean($renderer->isEmbeddable($mockExternalValidDocument, ['embed' => true])) + ->isEqualTo(false) + ->boolean($renderer->isEmbeddable($mockValidDocument, ['embed' => true])) + ->isEqualTo(false) + ->boolean($renderer->isEmbeddable($mockValidDocument, [])) + ->isEqualTo(false) + ->boolean($renderer->isEmbeddable($mockYoutubeDocument, [])) + ->isEqualTo(false) + ->boolean($renderer->isEmbeddable($mockYoutubeDocument, ['embed' => true])) + ->isEqualTo(true) + ; + } + + public function testSupports() + { + /** @var DocumentInterface $mockValidDocument */ + $mockValidDocument = new \mock\RZ\Roadiz\Documents\Models\SimpleDocument(); + $mockValidDocument->setFilename('file.jpg'); + $mockValidDocument->setMimeType('image/jpeg'); + + /** @var DocumentInterface $mockInvalidDocument */ + $mockInvalidDocument = new \mock\RZ\Roadiz\Documents\Models\SimpleDocument(); + $mockInvalidDocument->setFilename('file.psd'); + $mockInvalidDocument->setMimeType('image/vnd.adobe.photoshop'); + + /** @var DocumentInterface $mockExternalValidDocument */ + $mockExternalValidDocument = new \mock\RZ\Roadiz\Documents\Models\SimpleDocument(); + $mockExternalValidDocument->setFilename('file.jpg'); + $mockExternalValidDocument->setMimeType('image/jpeg'); + $mockExternalValidDocument->setEmbedId('xxxxx'); + $mockExternalValidDocument->setEmbedPlatform('getty'); + + $this + ->given($renderer = $this->newTestedInstance( + $this->getFilesystemOperator(), + $this->getEmbedFinderFactory(), + $this->getEnvironment(), + $this->getUrlGenerator() + )) + ->then + ->string($mockValidDocument->getMimeType()) + ->isEqualTo('image/jpeg') + ->boolean($renderer->supports($mockValidDocument, [])) + ->isEqualTo(false) + ->boolean($renderer->supports($mockValidDocument, ['picture' => true])) + ->isEqualTo(true) + ->boolean($renderer->isEmbeddable($mockExternalValidDocument, ['picture' => true, 'embed' => true])) + ->isEqualTo(false) + ->boolean($mockExternalValidDocument->isImage()) + ->isEqualTo(true) + ->boolean($renderer->supports($mockExternalValidDocument, ['picture' => true, 'embed' => true])) + ->isEqualTo(true) + ->boolean($renderer->supports($mockValidDocument, [ + 'picture' => true, + 'embed' => true, + ])) + ->isEqualTo(true) + ->string($mockInvalidDocument->getMimeType()) + ->isEqualTo('image/vnd.adobe.photoshop') + ->boolean($renderer->supports($mockInvalidDocument, [])) + ->isEqualTo(false); + } + + public function testRender() + { + /** @var DocumentInterface $mockDocument */ + $mockDocument = new \mock\RZ\Roadiz\Documents\Models\SimpleDocument(); + $mockDocument->setFilename('file.jpg'); + $mockDocument->setFolder('folder'); + $mockDocument->setMimeType('image/jpeg'); + + /** @var DocumentInterface $mockWebpDocument */ + $mockWebpDocument = new \mock\RZ\Roadiz\Documents\Models\SimpleDocument(); + $mockWebpDocument->setFilename('file.webp'); + $mockWebpDocument->setFolder('folder'); + $mockWebpDocument->setMimeType('image/webp'); + + $this + ->given($renderer = $this->newTestedInstance( + $this->getFilesystemOperator(), + $this->getEmbedFinderFactory(), + $this->getEnvironment(), + $this->getUrlGenerator() + )) + ->then + ->string($this->htmlTidy($renderer->render($mockDocument, [ + 'noProcess' => true, + 'picture' => true + ]))) + ->isEqualTo($this->htmlTidy(<< + + + file.jpg + +EOT + )) + ->string($this->htmlTidy($renderer->render($mockDocument, [ + 'noProcess' => true, + 'picture' => true, + 'loading' => 'lazy', + ]))) + ->isEqualTo($this->htmlTidy(<< + + + file.jpg + +EOT + )) + ->string($this->htmlTidy($renderer->render($mockWebpDocument, [ + 'noProcess' => true, + 'picture' => true + ]))) + ->isEqualTo($this->htmlTidy(<< + + file.webp + +EOT + )) + ->string($this->htmlTidy($renderer->render($mockDocument, [ + 'absolute' => true, + 'noProcess' => true, + 'picture' => true + ]))) + ->isEqualTo($this->htmlTidy(<< + + + file.jpg + +EOT + )) + ->string($this->htmlTidy($renderer->render($mockDocument, [ + 'width' => 300, + 'absolute' => true, + 'picture' => true + ]))) + ->isEqualTo($this->htmlTidy(<< + + +file.jpg + +EOT + )) + ->string($this->htmlTidy($renderer->render($mockDocument, [ + 'width' => 300, + 'class' => 'awesome-image responsive', + 'absolute' => true, + 'picture' => true + ]))) + ->isEqualTo($this->htmlTidy(<< + + +file.jpg + +EOT + )) + ->string($this->htmlTidy($renderer->render($mockDocument, [ + 'width' => 300, + 'lazyload' => true, + 'picture' => true + ]))) + ->endWith('') + ->isEqualTo($this->htmlTidy(<< + + + file.jpg + + +EOT + )) + ->string($this->htmlTidy($renderer->render($mockDocument, [ + 'width' => 300, + 'lazyload' => true, + 'picture' => true, + 'fallback' => 'https://test.test/fallback.png' + ]))) + ->endWith('') + ->isEqualTo($this->htmlTidy(<< + + + file.jpg + + +EOT + )) + ->string($this->htmlTidy($renderer->render($mockDocument, [ + 'width' => 300, + 'fallback' => 'https://test.test/fallback.png', + 'picture' => true + ]))) + ->isEqualTo($this->htmlTidy(<< + + + file.jpg + +EOT + )) + ->string($this->htmlTidy($renderer->render($mockDocument, [ + 'width' => 300, + 'lazyload' => true, + 'class' => 'awesome-image responsive', + 'picture' => true + ]))) + ->endWith('') + ->isEqualTo($this->htmlTidy(<< + + + file.jpg + + +EOT + )) + ->string($this->htmlTidy($renderer->render($mockDocument, [ + 'width' => 300, + 'srcset' => [[ + 'format' => [ + 'width' => 300 + ], + 'rule' => '1x' + ],[ + 'format' => [ + 'width' => 600 + ], + 'rule' => '2x' + ]], + 'picture' => true + ]))) + ->isEqualTo($this->htmlTidy(<< + + + file.jpg + +EOT + )) + ->string($this->htmlTidy($renderer->render($mockDocument, [ + 'width' => 300, + 'srcset' => [[ + 'format' => [ + 'width' => 300 + ], + 'rule' => '1x' + ],[ + 'format' => [ + 'width' => 600 + ], + 'rule' => '2x' + ]], + 'sizes' => [ + '(max-width: 767px) 300px', + '(min-width: 768px) 400px' + ], + 'picture' => true + ]))) + ->isEqualTo($this->htmlTidy(<< + + + file.jpg + +EOT + )) + ->string($this->htmlTidy($renderer->render($mockDocument, [ + 'fit' => '600x400', + 'srcset' => [[ + 'format' => [ + 'fit' => '600x400', + ], + 'rule' => '1x' + ],[ + 'format' => [ + 'fit' => '1200x800', + ], + 'rule' => '2x' + ]], + 'sizes' => [ + '(max-width: 767px) 300px', + '(min-width: 768px) 400px' + ], + 'picture' => true + ]))) + ->isEqualTo($this->htmlTidy(<< + + + file.jpg + +EOT + )) + + ->string($this->htmlTidy($renderer->render($mockDocument, [ + 'fit' => '600x400', + 'loading' => 'lazy', + 'srcset' => [[ + 'format' => [ + 'fit' => '600x400', + ], + 'rule' => '1x' + ],[ + 'format' => [ + 'fit' => '1200x800', + ], + 'rule' => '2x' + ]], + 'sizes' => [ + '(max-width: 767px) 300px', + '(min-width: 768px) 400px' + ], + 'picture' => true + ]))) + ->isEqualTo($this->htmlTidy(<< + + + file.jpg + +EOT + )) + ->string($this->htmlTidy($renderer->render($mockDocument, [ + 'fit' => '600x400', + 'lazyload' => true, + 'srcset' => [[ + 'format' => [ + 'fit' => '600x400', + ], + 'rule' => '1x' + ],[ + 'format' => [ + 'fit' => '1200x800', + ], + 'rule' => '2x' + ]], + 'picture' => true + ]))) + ->endWith('') + ->isEqualTo($this->htmlTidy(<< + + + file.jpg + + +EOT + )) + ->string($this->htmlTidy($renderer->render($mockDocument, [ + 'fit' => '600x400', + 'lazyload' => true, + 'fallback' => 'https://test.test/fallback.png', + 'srcset' => [[ + 'format' => [ + 'fit' => '600x400', + ], + 'rule' => '1x' + ],[ + 'format' => [ + 'fit' => '1200x800', + ], + 'rule' => '2x' + ]], + 'picture' => true + ]))) + ->endWith('') + ->isEqualTo($this->htmlTidy(<< + + + file.jpg + + +EOT + )) + ->string($this->htmlTidy($renderer->render($mockDocument, [ + 'fit' => '600x400', + 'media' => [[ + 'srcset' => [[ + 'format' => [ + 'fit' => '600x400', + ], + 'rule' => '1x' + ],[ + 'format' => [ + 'fit' => '1200x800', + ], + 'rule' => '2x' + ]], + 'rule' => '(min-width: 600px)' + ]], + 'picture' => true + ]))) + ->isEqualTo($this->htmlTidy(<< + + + file.jpg + +EOT + )) + ->string($this->htmlTidy($renderer->render($mockDocument, [ + 'fit' => '600x400', + 'media' => [[ + 'srcset' => [[ + 'format' => [ + 'fit' => '600x400', + ], + 'rule' => '1x' + ],[ + 'format' => [ + 'fit' => '1200x800', + ], + 'rule' => '2x' + ]], + 'rule' => '(min-width: 600px)' + ],[ + 'srcset' => [[ + 'format' => [ + 'fit' => '1200x800', + ], + 'rule' => '1x' + ],[ + 'format' => [ + 'fit' => '2400x1600', + ], + 'rule' => '2x' + ]], + 'rule' => '(min-width: 1200px)' + ]], + 'picture' => true + ]))) + ->isEqualTo($this->htmlTidy(<< + + + + + + + file.jpg + +EOT + )) + ->string($this->htmlTidy($renderer->render($mockDocument, [ + 'fit' => '600x400', + 'loading' => 'lazy', + 'media' => [[ + 'srcset' => [[ + 'format' => [ + 'fit' => '600x400', + ], + 'rule' => '1x' + ],[ + 'format' => [ + 'fit' => '1200x800', + ], + 'rule' => '2x' + ]], + 'rule' => '(min-width: 600px)' + ],[ + 'srcset' => [[ + 'format' => [ + 'fit' => '1200x800', + ], + 'rule' => '1x' + ],[ + 'format' => [ + 'fit' => '2400x1600', + ], + 'rule' => '2x' + ]], + 'rule' => '(min-width: 1200px)' + ]], + 'picture' => true + ]))) + ->isEqualTo($this->htmlTidy(<< + + + + + + + file.jpg + +EOT + )) + ->string($this->htmlTidy($renderer->render($mockWebpDocument, [ + 'fit' => '600x400', + 'lazyload' => true, + 'fallback' => 'FALLBACK', + 'media' => [[ + 'srcset' => [[ + 'format' => [ + 'fit' => '600x400', + ], + 'rule' => '1x' + ],[ + 'format' => [ + 'fit' => '1200x800', + ], + 'rule' => '2x' + ]], + 'rule' => '(min-width: 600px)' + ],[ + 'srcset' => [[ + 'format' => [ + 'fit' => '1200x800', + ], + 'rule' => '1x' + ],[ + 'format' => [ + 'fit' => '2400x1600', + ], + 'rule' => '2x' + ]], + 'rule' => '(min-width: 1200px)' + ]], + 'picture' => true + ]))) + ->isEqualTo($this->htmlTidy(<< + + + + + file.webp + + +EOT + )) + ->string($this->htmlTidy($renderer->render($mockWebpDocument, [ + 'fit' => '600x400', + 'lazyload' => true, + 'loading' => 'lazy', + 'fallback' => 'FALLBACK', + 'media' => [[ + 'srcset' => [[ + 'format' => [ + 'fit' => '600x400', + ], + 'rule' => '1x' + ],[ + 'format' => [ + 'fit' => '1200x800', + ], + 'rule' => '2x' + ]], + 'rule' => '(min-width: 600px)' + ],[ + 'srcset' => [[ + 'format' => [ + 'fit' => '1200x800', + ], + 'rule' => '1x' + ],[ + 'format' => [ + 'fit' => '2400x1600', + ], + 'rule' => '2x' + ]], + 'rule' => '(min-width: 1200px)' + ]], + 'picture' => true + ]))) + ->isEqualTo($this->htmlTidy(<< + + + + + file.webp + + +EOT + )) + ; + } + + /** + * @return \RZ\Roadiz\Documents\UrlGenerators\DocumentUrlGeneratorInterface + */ + private function getUrlGenerator(): DocumentUrlGeneratorInterface + { + return new \mock\RZ\Roadiz\Documents\UrlGenerators\DummyDocumentUrlGenerator(); + } + + private function getFilesystemOperator(): FilesystemOperator + { + return new MountManager([ + 'public' => new Filesystem( + new LocalFilesystemAdapter(dirname(__DIR__) . '/../../../files/'), + publicUrlGenerator: new class() implements PublicUrlGenerator + { + public function publicUrl(string $path, Config $config): string + { + return '/files/' . $path; + } + } + ), + 'private' => new Filesystem( + new LocalFilesystemAdapter(dirname(__DIR__) . '/../../../files/'), + publicUrlGenerator: new class() implements PublicUrlGenerator + { + public function publicUrl(string $path, Config $config): string + { + return '/files/' . $path; + } + } + ) + ]); + } + + private function htmlTidy(string $body): string + { + $body = preg_replace('#[\n\r\s]{2,}#', ' ', $body); + $body = str_replace("/", '/', $body); + $body = html_entity_decode($body); + return preg_replace('#\>[\n\r\s]+\<#', '><', $body); + } + + private function getEnvironment(): Environment + { + $loader = new FilesystemLoader([ + dirname(__DIR__) . '/../../../src/Resources/views' + ]); + return new Environment($loader, [ + 'autoescape' => false, + 'debug' => true + ]); + } + + /** + * @return \RZ\Roadiz\Documents\MediaFinders\EmbedFinderFactory + */ + private function getEmbedFinderFactory(): EmbedFinderFactory + { + return new EmbedFinderFactory([ + 'youtube' => \mock\RZ\Roadiz\Documents\MediaFinders\AbstractYoutubeEmbedFinder::class, + 'vimeo' => \mock\RZ\Roadiz\Documents\MediaFinders\AbstractVimeoEmbedFinder::class, + 'dailymotion' => \mock\RZ\Roadiz\Documents\MediaFinders\AbstractDailymotionEmbedFinder::class, + 'soundcloud' => \mock\RZ\Roadiz\Documents\MediaFinders\AbstractSoundcloudEmbedFinder::class, + ]); + } +} diff --git a/lib/Documents/tests/Roadiz/Document/Renderer/SvgRenderer.php b/lib/Documents/tests/Roadiz/Document/Renderer/SvgRenderer.php new file mode 100644 index 00000000..6871d90e --- /dev/null +++ b/lib/Documents/tests/Roadiz/Document/Renderer/SvgRenderer.php @@ -0,0 +1,95 @@ +setFilename('file.svg'); + $mockValidDocument->setMimeType('image/svg+xml'); + + $mockInvalidDocument->setFilename('file.jpg'); + $mockInvalidDocument->setMimeType('image/jpeg'); + + $this + ->given($renderer = $this->newTestedInstance($this->getFilesystemOperator())) + ->then + ->string($mockValidDocument->getMimeType()) + ->isEqualTo('image/svg+xml') + ->boolean($renderer->supports($mockValidDocument, [])) + ->isEqualTo(true) + ->boolean($renderer->supports($mockValidDocument, ['inline' => false])) + ->isEqualTo(true) + ->boolean($renderer->supports($mockValidDocument, ['inline' => true])) + ->isEqualTo(false) + ->string($mockInvalidDocument->getMimeType()) + ->isEqualTo('image/jpeg') + ->boolean($renderer->supports($mockInvalidDocument, [])) + ->isEqualTo(false); + } + + public function testRender() + { + /** @var DocumentInterface $mockDocument */ + $mockDocument = new \mock\RZ\Roadiz\Documents\Models\SimpleDocument(); + + $mockDocument->setFilename('file2.svg'); + $mockDocument->setFolder('folder'); + $mockDocument->setMimeType('image/svg+xml'); + + $this + ->given($renderer = $this->newTestedInstance($this->getFilesystemOperator())) + ->then + ->string($mockDocument->getMimeType()) + ->isEqualTo('image/svg+xml') + ->string($renderer->render($mockDocument, [])) + ->isEqualTo(<< +EOT +); + } + + private function getFilesystemOperator(): FilesystemOperator + { + return new MountManager([ + 'public' => new Filesystem( + new LocalFilesystemAdapter(dirname(__DIR__) . '/../../../files/'), + publicUrlGenerator: new class() implements PublicUrlGenerator + { + public function publicUrl(string $path, Config $config): string + { + return '/files/' . $path; + } + } + ), + 'private' => new Filesystem( + new LocalFilesystemAdapter(dirname(__DIR__) . '/../../../files/'), + publicUrlGenerator: new class() implements PublicUrlGenerator + { + public function publicUrl(string $path, Config $config): string + { + return '/files/' . $path; + } + } + ) + ]); + } +} diff --git a/lib/Documents/tests/Roadiz/Document/Renderer/VideoRenderer.php b/lib/Documents/tests/Roadiz/Document/Renderer/VideoRenderer.php new file mode 100644 index 00000000..a5149760 --- /dev/null +++ b/lib/Documents/tests/Roadiz/Document/Renderer/VideoRenderer.php @@ -0,0 +1,216 @@ +setFilename('file.mp4'); + $mockValidDocument->setMimeType('video/mp4'); + + /** @var DocumentInterface $mockInvalidDocument */ + $mockInvalidDocument = new \mock\RZ\Roadiz\Documents\Models\SimpleDocument(); + $mockInvalidDocument->setFilename('file.jpg'); + $mockInvalidDocument->setMimeType('image/jpeg'); + + $this + ->given($renderer = $this->newTestedInstance( + $this->getFilesystemOperator(), + $this->getDocumentFinder(), + $this->getEnvironment(), + $this->getUrlGenerator() + )) + ->then + ->string($mockValidDocument->getMimeType()) + ->isEqualTo('video/mp4') + ->boolean($renderer->supports($mockValidDocument, [])) + ->isEqualTo(true) + ->string($mockInvalidDocument->getMimeType()) + ->isEqualTo('image/jpeg') + ->boolean($renderer->supports($mockInvalidDocument, [])) + ->isEqualTo(false); + } + + public function testRender() + { + /** @var DocumentInterface $mockDocument */ + $mockDocument = new \mock\RZ\Roadiz\Documents\Models\SimpleDocument(); + $mockDocument->setFilename('file.mp4'); + $mockDocument->setFolder('folder'); + $mockDocument->setMimeType('video/mp4'); + + /** @var DocumentInterface $mockDocument2 */ + $mockDocument2 = new \mock\RZ\Roadiz\Documents\Models\SimpleDocument(); + $mockDocument2->setFilename('file2.ogg'); + $mockDocument2->setFolder('folder'); + $mockDocument2->setMimeType('video/ogg'); + + $this + ->given($renderer = $this->newTestedInstance( + $this->getFilesystemOperator(), + $this->getDocumentFinder(), + $this->getEnvironment(), + $this->getUrlGenerator() + )) + ->then + ->string($mockDocument->getMimeType()) + ->isEqualTo('video/mp4') + ->string($this->htmlTidy($renderer->render($mockDocument, []))) + ->isEqualTo($this->htmlTidy(<< + + +

Your browser does not support native video.

+ +EOT + )) + ->string($this->htmlTidy($renderer->render($mockDocument2, []))) + ->isEqualTo($this->htmlTidy(<< + +

Your browser does not support native video.

+ +EOT + )) + ->string($this->htmlTidy($renderer->render($mockDocument, [ + 'controls' => true, + 'loop' => true, + 'autoplay' => true, + ]))) + ->isEqualTo($this->htmlTidy(<< + + +

Your browser does not support native video.

+ +EOT + )) + ->string($this->htmlTidy($renderer->render($mockDocument, [ + 'controls' => true, + 'loop' => true, + 'autoplay' => true, + 'muted' => true, + ]))) + ->isEqualTo($this->htmlTidy(<< + + +

Your browser does not support native video.

+ +EOT + )) + ->string($this->htmlTidy($renderer->render($mockDocument, [ + 'controls' => false + ]))) + ->isEqualTo($this->htmlTidy(<< + + +

Your browser does not support native video.

+ +EOT + )) + ; + } + + /** + * @return DocumentFinderInterface + */ + private function getDocumentFinder(): DocumentFinderInterface + { + $finder = new ArrayDocumentFinder(); + + $finder->addDocument( + (new \mock\RZ\Roadiz\Documents\Models\SimpleDocument()) + ->setFilename('file.mp4') + ->setFolder('folder') + ->setMimeType('video/mp4') + ); + $finder->addDocument( + (new \mock\RZ\Roadiz\Documents\Models\SimpleDocument()) + ->setFilename('file.webm') + ->setFolder('folder') + ->setMimeType('video/webm') + ); + $finder->addDocument( + (new \mock\RZ\Roadiz\Documents\Models\SimpleDocument()) + ->setFilename('file2.ogg') + ->setFolder('folder') + ->setMimeType('video/ogg') + ); + + return $finder; + } + + /** + * @return DocumentUrlGeneratorInterface + */ + private function getUrlGenerator(): DocumentUrlGeneratorInterface + { + return new \mock\RZ\Roadiz\Documents\UrlGenerators\DummyDocumentUrlGenerator(); + } + + private function getFilesystemOperator(): FilesystemOperator + { + return new MountManager([ + 'public' => new Filesystem( + new LocalFilesystemAdapter(dirname(__DIR__) . '/../../../files/'), + publicUrlGenerator: new class() implements PublicUrlGenerator + { + public function publicUrl(string $path, Config $config): string + { + return '/files/' . $path; + } + } + ), + 'private' => new Filesystem( + new LocalFilesystemAdapter(dirname(__DIR__) . '/../../../files/'), + publicUrlGenerator: new class() implements PublicUrlGenerator + { + public function publicUrl(string $path, Config $config): string + { + return '/files/' . $path; + } + } + ) + ]); + } + + private function getEnvironment(): Environment + { + $loader = new FilesystemLoader([ + dirname(__DIR__) . '/../../../src/Resources/views' + ]); + return new Environment($loader, [ + 'autoescape' => false + ]); + } + + private function htmlTidy(string $body): string + { + $body = preg_replace('#[\n\r\s]{2,}#', ' ', $body); + $body = str_replace("/", '/', $body); + $body = html_entity_decode($body); + return preg_replace('#\>[\n\r\s]+\<#', '><', $body); + } +} diff --git a/lib/DtsGenerator b/lib/DtsGenerator deleted file mode 160000 index 28efe350..00000000 --- a/lib/DtsGenerator +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 28efe3500e4d2ab9ced6a7ee843d192738db7e2d diff --git a/lib/DtsGenerator/.github/workflows/run-test.yml b/lib/DtsGenerator/.github/workflows/run-test.yml new file mode 100644 index 00000000..2b6c87a4 --- /dev/null +++ b/lib/DtsGenerator/.github/workflows/run-test.yml @@ -0,0 +1,42 @@ +name: Static analysis and code style + +on: + push: + branches: + - main + - develop + - 'release/**' + - 'hotfix/**' + tags: ['**'] + pull_request: + types: + - opened + - reopened + - synchronize + - ready_for_review + +jobs: + static-analysis-tests: + runs-on: ubuntu-latest + strategy: + matrix: + php-version: ['8.0', '8.1'] + steps: + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + - uses: actions/checkout@v3 + - name: Cache Composer packages + id: composer-cache + uses: actions/cache@v3 + with: + path: vendor + key: ${{ runner.os }}-php-${{ matrix.php-version }}-${{ hashFiles('**/composer.lock') }} + restore-keys: | + ${{ runner.os }}-php-${{ matrix.php-version }}- + - name: Install Dependencies + run: composer install --no-scripts --no-ansi --no-interaction --no-progress + - name: Run PHP Code Sniffer + run: vendor/bin/phpcs -p ./src + - name: Run PHPStan + run: vendor/bin/phpstan analyse --no-progress -c phpstan.neon diff --git a/lib/DtsGenerator/.gitignore b/lib/DtsGenerator/.gitignore new file mode 100644 index 00000000..853e2f14 --- /dev/null +++ b/lib/DtsGenerator/.gitignore @@ -0,0 +1,8 @@ +composer.phar +/vendor/ + +report.txt +# Commit your application's lock file https://getcomposer.org/doc/01-basic-usage.md#commit-your-composer-lock-file-to-version-control +# You may choose to ignore a library lock file http://getcomposer.org/doc/02-libraries.md#lock-file +composer.lock +.phpcs-cache \ No newline at end of file diff --git a/lib/DtsGenerator/.travis.yml b/lib/DtsGenerator/.travis.yml new file mode 100644 index 00000000..ed91b5e8 --- /dev/null +++ b/lib/DtsGenerator/.travis.yml @@ -0,0 +1,16 @@ +language: php +sudo: required +php: + - 7.4 + - 8.0 + - 8.1 + - nightly +install: + - curl -s http://getcomposer.org/installer | php + - php composer.phar install --dev --no-interaction +script: + - vendor/bin/phpcs --report=full --report-file=./report.txt -p ./ + - vendor/bin/phpstan analyse -c phpstan.neon +jobs: + allow_failures: + - php: nightly diff --git a/lib/DtsGenerator/Makefile b/lib/DtsGenerator/Makefile new file mode 100644 index 00000000..9ab639a6 --- /dev/null +++ b/lib/DtsGenerator/Makefile @@ -0,0 +1,4 @@ +test: + vendor/bin/phpcbf --report=full --report-file=./report.txt -p ./ + vendor/bin/phpstan analyse -c phpstan.neon + diff --git a/lib/DtsGenerator/README.md b/lib/DtsGenerator/README.md new file mode 100644 index 00000000..7809e6dc --- /dev/null +++ b/lib/DtsGenerator/README.md @@ -0,0 +1,21 @@ +# dts-generator +Roadiz sub-package which generates Typescript interface declaration skeleton based on your schema. + +[![Unit tests, static analysis and code style](https://github.com/roadiz/dts-generator/actions/workflows/run-test.yml/badge.svg?branch=develop)](https://github.com/roadiz/dts-generator/actions/workflows/run-test.yml) + +### Usage + +```php +use RZ\Roadiz\Contracts\NodeType\NodeTypeInterface; +use RZ\Roadiz\Typescript\Declaration\DeclarationGeneratorFactory; +use RZ\Roadiz\Typescript\Declaration\Generators\DeclarationGenerator; +use Symfony\Component\HttpFoundation\ParameterBag; + +/** @var ParameterBag $nodeTypesBag */ +$nodeTypesBag = $serviceContainer->get('nodeTypesBag'); + +$declarationFactory = new DeclarationGeneratorFactory($nodeTypesBag); +$declaration = new DeclarationGenerator($declarationFactory); + +echo $declaration->getContents(); +``` diff --git a/lib/DtsGenerator/composer.json b/lib/DtsGenerator/composer.json new file mode 100644 index 00000000..c5b2b022 --- /dev/null +++ b/lib/DtsGenerator/composer.json @@ -0,0 +1,33 @@ +{ + "name": "roadiz/dts-generator", + "description": "Roadiz sub-package which generates Typescript interfaces skeleton based on your schema", + "type": "library", + "require": { + "roadiz/nodetype-contracts": "~1.1.2", + "symfony/http-foundation": "5.4.*" + }, + "require-dev": { + "phpstan/phpstan": "^1.5.3", + "squizlabs/php_codesniffer": "^3.5" + }, + "license": "MIT", + "authors": [ + { + "name": "Ambroise Maupate", + "email": "ambroise@roadiz.io", + "homepage": "https://www.roadiz.io", + "role": "Lead developer" + } + ], + "autoload": { + "psr-4": { + "RZ\\Roadiz\\Typescript\\Declaration\\": "src/" + } + }, + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev", + "dev-develop": "2.1.x-dev" + } + } +} diff --git a/lib/DtsGenerator/phpcs.xml.dist b/lib/DtsGenerator/phpcs.xml.dist new file mode 100644 index 00000000..97e3c88c --- /dev/null +++ b/lib/DtsGenerator/phpcs.xml.dist @@ -0,0 +1,17 @@ + + + + + + + + + + + + ./src + */node_modules + */.AppleDouble + */vendor + diff --git a/lib/DtsGenerator/phpstan.neon b/lib/DtsGenerator/phpstan.neon new file mode 100644 index 00000000..2359c3d9 --- /dev/null +++ b/lib/DtsGenerator/phpstan.neon @@ -0,0 +1,7 @@ +parameters: + level: max + paths: + - src + reportUnmatchedIgnoredErrors: false + checkGenericClassInNonGenericObjectType: false + checkMissingIterableValueType: false diff --git a/lib/DtsGenerator/src/DeclarationGeneratorFactory.php b/lib/DtsGenerator/src/DeclarationGeneratorFactory.php new file mode 100644 index 00000000..a30a3e20 --- /dev/null +++ b/lib/DtsGenerator/src/DeclarationGeneratorFactory.php @@ -0,0 +1,77 @@ +nodeTypesBag = $nodeTypesBag; + } + + /** + * @return ParameterBag + */ + public function getNodeTypesBag(): ParameterBag + { + return $this->nodeTypesBag; + } + + public function getHumanBool(bool $bool): string + { + return $bool ? 'true' : 'false'; + } + + /** + * @param NodeTypeInterface $nodeType + * + * @return NodeTypeGenerator + */ + public function createForNodeType(NodeTypeInterface $nodeType): NodeTypeGenerator + { + return new NodeTypeGenerator( + $nodeType, + $this + ); + } + + /** + * @param NodeTypeFieldInterface $field + * + * @return AbstractFieldGenerator + */ + public function createForNodeTypeField(NodeTypeFieldInterface $field): AbstractFieldGenerator + { + switch (true) { + case $field->isDocuments(): + return new DocumentsFieldGenerator($field, $this->nodeTypesBag); + case $field->isNodes(): + return new NodeReferencesFieldGenerator($field, $this->nodeTypesBag); + case $field->isChildrenNodes(): + return new ChildrenNodeFieldGenerator($field, $this->nodeTypesBag); + case $field->isMultiple(): + case $field->isEnum(): + return new EnumFieldGenerator($field, $this->nodeTypesBag); + default: + return new ScalarFieldGenerator($field, $this->nodeTypesBag); + } + } +} diff --git a/lib/DtsGenerator/src/Generators/AbstractFieldGenerator.php b/lib/DtsGenerator/src/Generators/AbstractFieldGenerator.php new file mode 100644 index 00000000..f4c2ed54 --- /dev/null +++ b/lib/DtsGenerator/src/Generators/AbstractFieldGenerator.php @@ -0,0 +1,78 @@ +field = $field; + $this->nodeTypesBag = $nodeTypesBag; + } + + protected function getNullableAssertion(): string + { + return '?'; + } + + abstract protected function getType(): string; + + private function getDeclaration(): string + { + return static::INDENTATION_MARK . + $this->field->getVarName() . + $this->getNullableAssertion() . ': ' . + $this->getType(); + } + + public function getContents(): string + { + return implode(PHP_EOL, [ + $this->getIntroduction(), + $this->getDeclaration() + ]); + } + + protected function getIntroductionLines(): array + { + $lines = [ + $this->field->getLabel(), + ]; + if (!empty($this->field->getDescription())) { + $lines[] = $this->field->getDescription(); + } + + if (!empty($this->field->getGroupName())) { + $lines[] = 'Group: ' . $this->field->getGroupName(); + } + + return $lines; + } + + /** + * @return string + */ + public function getIntroduction(): string + { + return implode(PHP_EOL, array_map(function (string $line) { + return static::INDENTATION_MARK . '// ' . $line; + }, $this->getIntroductionLines())); + } +} diff --git a/lib/DtsGenerator/src/Generators/ChildrenNodeFieldGenerator.php b/lib/DtsGenerator/src/Generators/ChildrenNodeFieldGenerator.php new file mode 100644 index 00000000..eb499d26 --- /dev/null +++ b/lib/DtsGenerator/src/Generators/ChildrenNodeFieldGenerator.php @@ -0,0 +1,30 @@ +getIntroduction(), + ]); + } + protected function getIntroductionLines(): array + { + $lines = [ + 'This node-type uses "blocks" which are available through parent RoadizNodesSources.blocks' + ]; + if (!empty($this->field->getDefaultValues())) { + $lines[] = 'Possible block node-types: ' . $this->field->getDefaultValues(); + } + return $lines; + } + + protected function getType(): string + { + return ''; + } +} diff --git a/lib/DtsGenerator/src/Generators/DeclarationGenerator.php b/lib/DtsGenerator/src/Generators/DeclarationGenerator.php new file mode 100644 index 00000000..b00591a2 --- /dev/null +++ b/lib/DtsGenerator/src/Generators/DeclarationGenerator.php @@ -0,0 +1,64 @@ + + */ + private array $nodeTypes; + + /** + * @param DeclarationGeneratorFactory $generatorFactory + * @param NodeTypeInterface[] $nodeTypes + */ + public function __construct(DeclarationGeneratorFactory $generatorFactory, array $nodeTypes = []) + { + $this->generatorFactory = $generatorFactory; + + if (empty($nodeTypes)) { + $this->nodeTypes = array_unique($this->generatorFactory->getNodeTypesBag()->all()); + } else { + $this->nodeTypes = $nodeTypes; + } + } + + public function getContents(): string + { + $blocks = [ + $this->getHeader(), + ]; + + foreach ($this->nodeTypes as $nodeType) { + $blocks[] = $this->generatorFactory->createForNodeType($nodeType)->getContents(); + } + + return implode(PHP_EOL . PHP_EOL, $blocks); + } + + private function getHeader(): string + { + return <<'; + } +} diff --git a/lib/DtsGenerator/src/Generators/EnumFieldGenerator.php b/lib/DtsGenerator/src/Generators/EnumFieldGenerator.php new file mode 100644 index 00000000..ad89244f --- /dev/null +++ b/lib/DtsGenerator/src/Generators/EnumFieldGenerator.php @@ -0,0 +1,43 @@ +field->isEnum(): + if (!empty($this->field->getDefaultValues())) { + $defaultValues = array_filter( + array_map( + 'trim', + explode(',', $this->field->getDefaultValues()) + ) + ); + if (count($defaultValues) > 0) { + $defaultValues = array_map(function (string $value) { + return '\'' . $value . '\''; + }, $defaultValues); + return implode(' | ', $defaultValues) . ' | null'; + } + } + return 'string'; + case $this->field->isMultiple(): + return 'Array'; + default: + return 'any'; + } + } + + protected function getIntroductionLines(): array + { + $lines = parent::getIntroductionLines(); + if (!empty($this->field->getDefaultValues())) { + $lines[] = 'Possible values: ' . $this->field->getDefaultValues(); + } + return $lines; + } +} diff --git a/lib/DtsGenerator/src/Generators/NodeReferencesFieldGenerator.php b/lib/DtsGenerator/src/Generators/NodeReferencesFieldGenerator.php new file mode 100644 index 00000000..38332fe5 --- /dev/null +++ b/lib/DtsGenerator/src/Generators/NodeReferencesFieldGenerator.php @@ -0,0 +1,57 @@ +getUnionType() . '>'; + } + + /** + * @return array + */ + private function getLinkedNodeTypes(): array + { + if (null === $this->field->getDefaultValues()) { + return []; + } + $nodeTypeNames = explode(',', $this->field->getDefaultValues()); + return array_values(array_filter(array_map(function (string $name) { + $nodeType = $this->nodeTypesBag->get(trim($name)); + return $nodeType instanceof NodeTypeInterface ? $nodeType : null; + }, $nodeTypeNames))); + } + + private function getUnionType(): string + { + $nodeTypes = $this->getLinkedNodeTypes(); + + if (empty($nodeTypes)) { + return 'RoadizNodesSources'; + } + + return implode(' | ', array_map(function (NodeTypeInterface $nodeType) { + return $nodeType->getSourceEntityClassName(); + }, $nodeTypes)); + } + + protected function getIntroductionLines(): array + { + $lines = parent::getIntroductionLines(); + if (!empty($this->field->getDefaultValues())) { + $lines[] = 'Possible values: ' . $this->field->getDefaultValues(); + } + return $lines; + } +} diff --git a/lib/DtsGenerator/src/Generators/NodeTypeGenerator.php b/lib/DtsGenerator/src/Generators/NodeTypeGenerator.php new file mode 100644 index 00000000..f55ebd56 --- /dev/null +++ b/lib/DtsGenerator/src/Generators/NodeTypeGenerator.php @@ -0,0 +1,99 @@ + + */ + private array $fieldGenerators; + private DeclarationGeneratorFactory $generatorFactory; + + /** + * @param NodeTypeInterface $nodeType + * @param DeclarationGeneratorFactory $generatorFactory + */ + public function __construct( + NodeTypeInterface $nodeType, + DeclarationGeneratorFactory $generatorFactory + ) { + $this->nodeType = $nodeType; + $this->fieldGenerators = []; + $this->generatorFactory = $generatorFactory; + + /** @var NodeTypeFieldInterface $field */ + foreach ($this->nodeType->getFields() as $field) { + if (!($field instanceof SerializableInterface) || !$field->isExcludedFromSerialization()) { + $this->fieldGenerators[] = $this->generatorFactory->createForNodeTypeField($field); + } + } + } + + public function getContents(): string + { + /* + * interface NSPage extends RoadizNodesSources { + * image: Array + * head: NSPageHead + * headerImage: Array + * excerpt: string + * linkLabel: string + * linkUrl: string + * linkReference: Array + * linkDownload: Array + * color: string + * } + */ + return implode(PHP_EOL, [ + $this->getIntroduction(), + $this->getInterfaceBody() + ]); + } + + protected function getInterfaceBody(): string + { + $lines = [ + 'interface ' . $this->nodeType->getSourceEntityClassName() . ' extends RoadizNodesSources {', + $this->getFieldsContents(), + '}' + ]; + + return implode(PHP_EOL, $lines); + } + + protected function getIntroduction(): string + { + $lines = [ + '', + $this->nodeType->getLabel(), + '' + ]; + if (!empty($this->nodeType->getDescription())) { + $lines[] = $this->nodeType->getDescription(); + } + + $lines[] = 'Reachable: ' . $this->generatorFactory->getHumanBool($this->nodeType->isReachable()); + $lines[] = 'Publishable: ' . $this->generatorFactory->getHumanBool($this->nodeType->isPublishable()); + $lines[] = 'Visible: ' . $this->generatorFactory->getHumanBool($this->nodeType->isVisible()); + + return implode(PHP_EOL, array_map(function (string $line) { + return '// ' . $line; + }, $lines)); + } + + protected function getFieldsContents(): string + { + return implode(PHP_EOL, array_map(function (AbstractFieldGenerator $abstractFieldGenerator) { + return $abstractFieldGenerator->getContents(); + }, $this->fieldGenerators)); + } +} diff --git a/lib/DtsGenerator/src/Generators/ScalarFieldGenerator.php b/lib/DtsGenerator/src/Generators/ScalarFieldGenerator.php new file mode 100644 index 00000000..006ab2a5 --- /dev/null +++ b/lib/DtsGenerator/src/Generators/ScalarFieldGenerator.php @@ -0,0 +1,37 @@ +field->isString(): + case $this->field->isRichText(): + case $this->field->isText(): + case $this->field->isMarkdown(): + case $this->field->isCss(): + case $this->field->isColor(): + case $this->field->isCountry(): + case $this->field->isDate(): + case $this->field->isDateTime(): + case $this->field->isGeoTag(): + case $this->field->isMultiGeoTag(): + return 'string'; + case $this->field->isBool(): + return 'boolean'; + case $this->field->isDecimal(): + case $this->field->isInteger(): + return 'number'; + case $this->field->isCollection(): + case $this->field->isMultiProvider(): + // Data cannot be known, this depends on user configuration + return 'Array'; + default: + return 'unknown'; + } + } +} diff --git a/lib/EntityGenerator b/lib/EntityGenerator deleted file mode 160000 index fc27fae4..00000000 --- a/lib/EntityGenerator +++ /dev/null @@ -1 +0,0 @@ -Subproject commit fc27fae4612c5b54e2c4b4ce8758a9cc3f99b9cc diff --git a/lib/EntityGenerator/.editorconfig b/lib/EntityGenerator/.editorconfig new file mode 100644 index 00000000..c3ccb818 --- /dev/null +++ b/lib/EntityGenerator/.editorconfig @@ -0,0 +1,17 @@ +# Roadiz editor config for contributors +# http://editorconfig.org/ +root = true + +[*] +indent_style = space +indent_size = 4 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.md] +trim_trailing_whitespace = false + +[Makefile] +indent_style = tab diff --git a/lib/EntityGenerator/.github/workflows/run-test.yml b/lib/EntityGenerator/.github/workflows/run-test.yml new file mode 100644 index 00000000..1a24c5b8 --- /dev/null +++ b/lib/EntityGenerator/.github/workflows/run-test.yml @@ -0,0 +1,43 @@ +name: Unit tests, static analysis and code style + +on: + push: + branches: + - develop + - 'release/**' + - 'hotfix/**' + tags: ['**'] + pull_request: + types: + - opened + - reopened + - synchronize + - ready_for_review + +jobs: + run-tests: + runs-on: ubuntu-latest + strategy: + matrix: + php-version: ['8.0', '8.1'] + steps: + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + - uses: actions/checkout@v3 + - name: Cache Composer packages + id: composer-cache + uses: actions/cache@v3 + with: + path: vendor + key: ${{ runner.os }}-php-${{ matrix.php-version }}-${{ hashFiles('**/composer.lock') }} + restore-keys: | + ${{ runner.os }}-php-${{ matrix.php-version }}- + - name: Install Dependencies + run: composer install --no-scripts --no-ansi --no-interaction --no-progress + - name: Run PHP Code Sniffer + run: vendor/bin/phpcs --extensions=php --warning-severity=0 --standard=PSR12 -p ./src + - name: Run PHPStan + run: vendor/bin/phpstan analyse --no-progress -c phpstan.neon + - name: Run atoum unit tests + run: vendor/bin/atoum -f tests/units/* diff --git a/lib/EntityGenerator/.gitignore b/lib/EntityGenerator/.gitignore new file mode 100644 index 00000000..1fd26870 --- /dev/null +++ b/lib/EntityGenerator/.gitignore @@ -0,0 +1,5 @@ +### Composer ### +composer.phar +/vendor/ +/report.txt +/composer.lock diff --git a/lib/EntityGenerator/.travis.yml b/lib/EntityGenerator/.travis.yml new file mode 100644 index 00000000..87d3a8b9 --- /dev/null +++ b/lib/EntityGenerator/.travis.yml @@ -0,0 +1,18 @@ +language: php +sudo: required +php: + - 8.0 + - 8.1 + - nightly +env: + - XDEBUG_MODE=coverage +install: + - curl -s http://getcomposer.org/installer | php + - php composer.phar install --dev --no-interaction +script: + - vendor/bin/phpcs --report=full --report-file=./report.txt --extensions=php --warning-severity=0 --standard=PSR12 -p ./src + - vendor/bin/phpstan analyse -c phpstan.neon + - vendor/bin/atoum -f tests/units/* +jobs: + allow_failures: + - php: nightly diff --git a/lib/EntityGenerator/CHANGELOG.md b/lib/EntityGenerator/CHANGELOG.md new file mode 100644 index 00000000..822ee16d --- /dev/null +++ b/lib/EntityGenerator/CHANGELOG.md @@ -0,0 +1,24 @@ +## 2.0.3 (2022-07-20) + +### Features + +* Added clone override method to clone proxies relationships ([fcc99d0](https://github.com/roadiz/entity-generator/commit/fcc99d07d105683deeeab4baafa480bb2ceae36f)) + +## 2.0.2 (2022-07-20) + +### Bug Fixes + +* Missing `$this->objectManager->persist($proxyEntity)` after creating proxy objects ([9e3fc09](https://github.com/roadiz/entity-generator/commit/9e3fc09491dc837a8e9dd123ba1baaf73e7ee6bc)) + +## 2.0.1 (2022-06-28) + +### Features + +* Added examples for many-to-many/one and proxied ([3a3ad1c](https://github.com/roadiz/entity-generator/commit/3a3ad1c17741b7b50bdf1c968c4797cb0431401a)) +* All multi-valued fields should be JSON by default ([59e05f7](https://github.com/roadiz/entity-generator/commit/59e05f7d79288fa418dc4aa35406c5fc39ae81a7)) + +### Bug Fixes + +* Missing SymfonySerializer\Ignore ([6e9876f](https://github.com/roadiz/entity-generator/commit/6e9876f6fc165687f80f825ea6a4989255737c5d)) +* Multiple fields must be array or null ([319d84b](https://github.com/roadiz/entity-generator/commit/319d84be32420ad175dcc69bff675cbf26d9f2c4)) + diff --git a/lib/EntityGenerator/LICENSE b/lib/EntityGenerator/LICENSE new file mode 100644 index 00000000..9e67c849 --- /dev/null +++ b/lib/EntityGenerator/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Roadiz + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/lib/EntityGenerator/Makefile b/lib/EntityGenerator/Makefile new file mode 100644 index 00000000..c45718ce --- /dev/null +++ b/lib/EntityGenerator/Makefile @@ -0,0 +1,4 @@ +test: + vendor/bin/phpcbf --report=full --report-file=./report.txt --extensions=php --warning-severity=0 --standard=PSR12 -p ./src + vendor/bin/phpstan analyse -c phpstan.neon + vendor/bin/atoum -f tests/units/* diff --git a/lib/EntityGenerator/README.md b/lib/EntityGenerator/README.md new file mode 100644 index 00000000..4fdd39de --- /dev/null +++ b/lib/EntityGenerator/README.md @@ -0,0 +1,5 @@ +# entity-generator + +[![Unit tests, static analysis and code style](https://github.com/roadiz/entity-generator/actions/workflows/run-test.yml/badge.svg?branch=develop)](https://github.com/roadiz/entity-generator/actions/workflows/run-test.yml) + +Roadiz sub-package which generate Doctrine entity classes for node-sources diff --git a/lib/EntityGenerator/composer.json b/lib/EntityGenerator/composer.json new file mode 100644 index 00000000..8b88444b --- /dev/null +++ b/lib/EntityGenerator/composer.json @@ -0,0 +1,44 @@ +{ + "name": "roadiz/entity-generator", + "description": "Roadiz sub-package which generate Doctrine entity classes for node-sources", + "type": "library", + "license": "MIT", + "authors": [ + { + "name": "Ambroise Maupate", + "email": "ambroise@roadiz.io", + "homepage": "https://www.roadiz.io", + "role": "Lead developer" + } + ], + "require": { + "php": ">=8.0", + "ext-json": "*", + "roadiz/nodetype-contracts": "~1.1.2", + "symfony/string": "5.4.*", + "symfony/yaml": "5.4.*", + "symfony/serializer": "5.4.*", + "symfony/options-resolver": "5.4.*" + }, + "suggest": { + "api-platform/core": "If you need to create ApiFilter annotation right into your entities" + }, + "autoload": { + "psr-4": { + "RZ\\Roadiz\\EntityGenerator\\": "src/", + "tests\\mocks\\": "tests/mocks/" + } + }, + "require-dev": { + "squizlabs/php_codesniffer": "^3.5", + "phpstan/phpstan": "^1.5.3", + "atoum/atoum": "^4.0.0", + "api-platform/core": "~2.7.0" + }, + "extra": { + "branch-alias": { + "dev-main": "2.0.x-dev", + "dev-develop": "2.1.x-dev" + } + } +} diff --git a/lib/EntityGenerator/phpstan.neon b/lib/EntityGenerator/phpstan.neon new file mode 100644 index 00000000..c86467e9 --- /dev/null +++ b/lib/EntityGenerator/phpstan.neon @@ -0,0 +1,11 @@ +parameters: + level: max + paths: + - src + excludePaths: + - */node_modules/* + - */bower_components/* + - */static/* + reportUnmatchedIgnoredErrors: false + checkMissingIterableValueType: false + checkGenericClassInNonGenericObjectType: false diff --git a/lib/EntityGenerator/src/Attribute/AttributeGenerator.php b/lib/EntityGenerator/src/Attribute/AttributeGenerator.php new file mode 100644 index 00000000..2b880f0e --- /dev/null +++ b/lib/EntityGenerator/src/Attribute/AttributeGenerator.php @@ -0,0 +1,78 @@ + + */ + protected array $parameters; + + /** + * @param string $className + * @param int[]|string[] $parameters + */ + public function __construct(string $className, array $parameters = []) + { + $this->className = $className; + $this->parameters = $parameters; + } + + public static function wrapString(string $string): string + { + return sprintf('"%s"', $string); + } + + public function generate(int $currentIndentation = 0): string + { + $formattedParams = []; + if (count($this->parameters) > 3) { + foreach ($this->parameters as $name => $parameter) { + if (is_string($name) && !empty($name)) { + $formattedParams[] = sprintf( + '%s%s: %s', + str_repeat(' ', $currentIndentation + 4), + $name, + $parameter + ); + } else { + $formattedParams[] = sprintf( + '%s%s', + str_repeat(' ', $currentIndentation + 4), + $parameter + ); + } + } + return + str_repeat(' ', $currentIndentation) . + $this->className . + sprintf( + '(%s%s%s)', + PHP_EOL, + implode(',' . PHP_EOL, $formattedParams), + PHP_EOL . str_repeat(' ', $currentIndentation), + ); + } elseif (count($this->parameters) > 0) { + foreach ($this->parameters as $name => $parameter) { + if (is_string($name) && !empty($name)) { + $formattedParams[] = sprintf('%s: %s', $name, $parameter); + } else { + $formattedParams[] = $parameter; + } + } + return + str_repeat(' ', $currentIndentation) . + $this->className . + sprintf( + '(%s)', + implode(', ', $formattedParams) + ); + } else { + return str_repeat(' ', $currentIndentation) . $this->className; + } + } +} diff --git a/lib/EntityGenerator/src/Attribute/AttributeListGenerator.php b/lib/EntityGenerator/src/Attribute/AttributeListGenerator.php new file mode 100644 index 00000000..5640684d --- /dev/null +++ b/lib/EntityGenerator/src/Attribute/AttributeListGenerator.php @@ -0,0 +1,43 @@ + + */ + public array $attributes; + + /** + * @param AttributeGenerator[] $attributes + */ + public function __construct(array $attributes) + { + $this->attributes = $attributes; + } + + public function generate(int $currentIndentation = 0): string + { + if (count($this->attributes) === 0) { + return ''; + } + if (count($this->attributes) === 1) { + return sprintf('#[%s]', reset($this->attributes)->generate($currentIndentation)); + } + + return sprintf( + '%s#[%s%s%s]', + str_repeat(' ', $currentIndentation), + PHP_EOL, + implode(',' . PHP_EOL, array_map(function (AttributeGenerator $attributeGenerator) use ($currentIndentation) { + return $attributeGenerator->generate($currentIndentation + 4); + }, $this->attributes)), + PHP_EOL . str_repeat(' ', $currentIndentation), + ); + } +} diff --git a/lib/EntityGenerator/src/ClassGeneratorInterface.php b/lib/EntityGenerator/src/ClassGeneratorInterface.php new file mode 100644 index 00000000..7b301c19 --- /dev/null +++ b/lib/EntityGenerator/src/ClassGeneratorInterface.php @@ -0,0 +1,10 @@ +configureOptions($resolver); + + $this->nodeType = $nodeType; + $this->nodeTypeResolver = $nodeTypeResolver; + $this->fieldGenerators = []; + $this->options = $resolver->resolve($options); + + foreach ($this->nodeType->getFields() as $field) { + $this->fieldGenerators[] = $this->getFieldGenerator($field); + } + $this->fieldGenerators = array_filter($this->fieldGenerators); + } + + /** + * @param OptionsResolver $resolver + */ + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'use_native_json' => true, + 'use_api_platform_filters' => false, + ]); + $resolver->setRequired([ + 'parent_class', + 'node_class', + 'translation_class', + 'document_class', + 'document_proxy_class', + 'custom_form_class', + 'custom_form_proxy_class', + 'repository_class', + 'namespace', + 'use_native_json', + 'use_api_platform_filters' + ]); + $resolver->setAllowedTypes('parent_class', 'string'); + $resolver->setAllowedTypes('node_class', 'string'); + $resolver->setAllowedTypes('translation_class', 'string'); + $resolver->setAllowedTypes('document_class', 'string'); + $resolver->setAllowedTypes('document_proxy_class', 'string'); + $resolver->setAllowedTypes('custom_form_class', 'string'); + $resolver->setAllowedTypes('custom_form_proxy_class', 'string'); + $resolver->setAllowedTypes('repository_class', 'string'); + $resolver->setAllowedTypes('namespace', 'string'); + $resolver->setAllowedTypes('use_native_json', 'bool'); + $resolver->setAllowedTypes('use_api_platform_filters', 'bool'); + + $normalizeClassName = function (OptionsResolver $resolver, string $className) { + return (new UnicodeString($className))->startsWith('\\') ? + $className : + '\\' . $className; + }; + + $resolver->setNormalizer('parent_class', $normalizeClassName); + $resolver->setNormalizer('node_class', $normalizeClassName); + $resolver->setNormalizer('translation_class', $normalizeClassName); + $resolver->setNormalizer('document_class', $normalizeClassName); + $resolver->setNormalizer('document_proxy_class', $normalizeClassName); + $resolver->setNormalizer('custom_form_class', $normalizeClassName); + $resolver->setNormalizer('custom_form_proxy_class', $normalizeClassName); + $resolver->setNormalizer('repository_class', $normalizeClassName); + $resolver->setNormalizer('namespace', $normalizeClassName); + } + + /** + * @param NodeTypeFieldInterface $field + * @return AbstractFieldGenerator|null + */ + protected function getFieldGenerator(NodeTypeFieldInterface $field): ?AbstractFieldGenerator + { + if ($field->isYaml()) { + return new YamlFieldGenerator($field, $this->options); + } + if ($field->isCollection()) { + return new CollectionFieldGenerator($field, $this->options); + } + if ($field->isCustomForms()) { + return new CustomFormsFieldGenerator($field, $this->options); + } + if ($field->isDocuments()) { + return new DocumentsFieldGenerator($field, $this->options); + } + if ($field->isManyToOne()) { + return new ManyToOneFieldGenerator($field, $this->options); + } + if ($field->isManyToMany()) { + $configuration = Yaml::parse($field->getDefaultValues() ?? ''); + if ( + is_array($configuration) && + isset($configuration['proxy']) && + !empty($configuration['proxy']['classname']) + ) { + /* + * Manually create a Many-to-Many relation using a proxy class + * for handling position for example. + */ + return new ProxiedManyToManyFieldGenerator($field, $this->options); + } + return new ManyToManyFieldGenerator($field, $this->options); + } + if ($field->isNodes()) { + return new NodesFieldGenerator($field, $this->nodeTypeResolver, $this->options); + } + if (!$field->isVirtual()) { + return new NonVirtualFieldGenerator($field, $this->options); + } + + return null; + } + + /** + * @return string + */ + public function getClassContent(): string + { + return $this->getClassHeader() . + $this->getClassAnnotations() . + $this->getClassAttributes() . + $this->getClassBody(); + } + + /** + * @return string + */ + protected function getClassBody(): string + { + return 'class ' . $this->nodeType->getSourceEntityClassName() . ' extends ' . $this->options['parent_class'] . ' +{' . $this->getClassProperties() . + $this->getClassConstructor() . + $this->getNodeTypeNameGetter() . + $this->getNodeTypeReachableGetter() . + $this->getNodeTypePublishableGetter() . + $this->getClassCloneMethod() . + $this->getClassMethods() . ' +}' . PHP_EOL; + } + + /** + * @return string + */ + protected function getClassHeader(): string + { + $useStatements = [ + 'use Doctrine\Common\Collections\Collection;', + 'use JMS\Serializer\Annotation as Serializer;', + 'use Symfony\Component\Serializer\Annotation as SymfonySerializer;', + 'use Gedmo\Mapping\Annotation as Gedmo;', + 'use Doctrine\ORM\Mapping as ORM;', + ]; + + if ($this->options['use_api_platform_filters'] === true) { + $useStatements[] = 'use ApiPlatform\Core\Annotation\ApiFilter;'; + $useStatements[] = 'use ApiPlatform\Core\Bridge\Doctrine\Orm\Filter as OrmFilter;'; + $useStatements[] = 'use ApiPlatform\Core\Serializer\Filter\PropertyFilter;'; + } + /* + * BE CAREFUL, USE statements are required for field generators which + * are using ::class syntax! + */ + return 'options['namespace'], '\\') . '; + +' . implode(PHP_EOL, $useStatements) . PHP_EOL; + } + + protected function getClassAttributes(): string + { + $attributeGenerators = [ + new AttributeGenerator('Gedmo\Loggable', [ + 'logEntryClass' => '\RZ\Roadiz\CoreBundle\Entity\UserLogEntry::class', + ]), + new AttributeGenerator('ORM\Entity', [ + 'repositoryClass' => $this->options['repository_class'] . '::class', + ]), + new AttributeGenerator('ORM\Table', [ + 'name' => AttributeGenerator::wrapString($this->nodeType->getSourceEntityTableName()) + ]) + ]; + + $indexes = []; + /** @var AbstractFieldGenerator $fieldGenerator */ + foreach ($this->fieldGenerators as $fieldGenerator) { + $indexes[] = $fieldGenerator->getFieldIndex(); + } + $attributeGenerators = [...$attributeGenerators, ...array_filter($indexes)]; + + if ($this->options['use_api_platform_filters'] === true) { + $attributeGenerators[] = new AttributeGenerator('ApiFilter', [ + 'PropertyFilter::class' + ]); + } + + return (new AttributeListGenerator($attributeGenerators))->generate() . PHP_EOL; + } + + /** + * @return string + */ + protected function getClassAnnotations(): string + { + return ' +/** + * DO NOT EDIT + * Generated custom node-source type by Roadiz. + */' . PHP_EOL; + } + + /** + * @return string + */ + protected function getClassProperties(): string + { + $fieldsArray = []; + /** @var AbstractFieldGenerator $fieldGenerator */ + foreach ($this->fieldGenerators as $fieldGenerator) { + $fieldsArray[] = $fieldGenerator->getField(); + } + $fieldsArray = array_filter($fieldsArray); + + return implode('', $fieldsArray); + } + + /** + * @return string + */ + protected function getClassCloneMethod(): string + { + $cloneStatements = []; + /** @var AbstractFieldGenerator $fieldGenerator */ + foreach ($this->fieldGenerators as $fieldGenerator) { + $cloneStatements[] = trim($fieldGenerator->getCloneStatements()); + } + $cloneStatements = array_filter($cloneStatements); + + if (count($cloneStatements) === 0) { + return ''; + } + + $statementSeparator = PHP_EOL . PHP_EOL . AbstractFieldGenerator::TAB . AbstractFieldGenerator::TAB; + $cloneStatementsString = implode($statementSeparator, $cloneStatements); + + return ' + public function __clone() + { + parent::__clone(); + + ' . $cloneStatementsString . ' + }' . PHP_EOL; + } + + /** + * @return string + */ + protected function getClassConstructor(): string + { + $constructorArray = []; + /** @var AbstractFieldGenerator $fieldGenerator */ + foreach ($this->fieldGenerators as $fieldGenerator) { + $constructorArray[] = $fieldGenerator->getFieldConstructorInitialization(); + } + $constructorArray = array_filter($constructorArray); + + if (count($constructorArray) > 0) { + return ' + public function __construct(' . $this->options['node_class'] . ' $node, ' . $this->options['translation_class'] . ' $translation) + { + parent::__construct($node, $translation); + + ' . implode(PHP_EOL . AbstractFieldGenerator::TAB . AbstractFieldGenerator::TAB, $constructorArray) . ' + }' . PHP_EOL; + } + + return ''; + } + + /** + * @return string + */ + protected function getNodeTypeNameGetter(): string + { + return ' + #[ + Serializer\VirtualProperty, + Serializer\Groups(["nodes_sources", "nodes_sources_default"]), + Serializer\SerializedName("@type"), + SymfonySerializer\Groups(["nodes_sources", "nodes_sources_default"]), + SymfonySerializer\SerializedName(serializedName: "@type") + ] + public function getNodeTypeName(): string + { + return \'' . $this->nodeType->getName() . '\'; + }' . PHP_EOL; + } + + /** + * @return string + */ + protected function getNodeTypeReachableGetter(): string + { + return ' + /** + * $this->nodeType->isReachable() proxy. + * + * @return bool Does this nodeSource is reachable over network? + */ + public function isReachable(): bool + { + return ' . ($this->nodeType->isReachable() ? 'true' : 'false') . '; + }' . PHP_EOL; + } + + /** + * @return string + */ + protected function getNodeTypePublishableGetter(): string + { + return ' + /** + * $this->nodeType->isPublishable() proxy. + * + * @return bool Does this nodeSource is publishable with date and time? + */ + public function isPublishable(): bool + { + return ' . ($this->nodeType->isPublishable() ? 'true' : 'false') . '; + }' . PHP_EOL; + } + + /** + * @return string + */ + protected function getClassMethods(): string + { + return ' + public function __toString(): string + { + return \'[' . $this->nodeType->getSourceEntityClassName() . '] \' . parent::__toString(); + }'; + } +} diff --git a/lib/EntityGenerator/src/EntityGeneratorFactory.php b/lib/EntityGenerator/src/EntityGeneratorFactory.php new file mode 100644 index 00000000..7a68d0a6 --- /dev/null +++ b/lib/EntityGenerator/src/EntityGeneratorFactory.php @@ -0,0 +1,52 @@ +nodeTypeResolverBag = $nodeTypeResolverBag; + $this->options = $options; + } + + public function create(NodeTypeInterface $nodeType): EntityGeneratorInterface + { + return new EntityGenerator($nodeType, $this->nodeTypeResolverBag, $this->options); + } + + public function createWithCustomRepository(NodeTypeInterface $nodeType): EntityGeneratorInterface + { + $options = $this->options; + $options['repository_class'] = + $options['namespace'] . + '\\Repository\\' . + $nodeType->getSourceEntityClassName() . 'Repository'; + + return new EntityGenerator($nodeType, $this->nodeTypeResolverBag, $options); + } + + public function createCustomRepository(NodeTypeInterface $nodeType): RepositoryGeneratorInterface + { + $options = [ + 'entity_namespace' => $this->options['namespace'], + 'parent_class' => 'RZ\Roadiz\CoreBundle\Repository\NodesSourcesRepository', + ]; + $options['namespace'] = $this->options['namespace'] . '\\Repository'; + $options['class_name'] = $nodeType->getSourceEntityClassName() . 'Repository'; + + return new RepositoryGenerator($nodeType, $options); + } +} diff --git a/lib/EntityGenerator/src/EntityGeneratorInterface.php b/lib/EntityGenerator/src/EntityGeneratorInterface.php new file mode 100644 index 00000000..9f0eda76 --- /dev/null +++ b/lib/EntityGenerator/src/EntityGeneratorInterface.php @@ -0,0 +1,12 @@ +field->getDefaultValues())) { + throw new \LogicException('Default values must be a valid YAML for ' . static::class); + } + $conf = Yaml::parse($this->field->getDefaultValues()); + if (!is_array($conf)) { + throw new \LogicException('YAML for ' . static::class . ' must be an associative array'); + } + $this->configuration = $conf; + } + + /** + * Ensure configured classname has a starting backslash. + * + * @return string + */ + protected function getFullyQualifiedClassName(): string + { + return '\\' . trim($this->configuration['classname'], '\\'); + } +} diff --git a/lib/EntityGenerator/src/Field/AbstractFieldGenerator.php b/lib/EntityGenerator/src/Field/AbstractFieldGenerator.php new file mode 100644 index 00000000..7721207b --- /dev/null +++ b/lib/EntityGenerator/src/Field/AbstractFieldGenerator.php @@ -0,0 +1,422 @@ +field = $field; + $this->options = $options; + } + + /** + * @param array $ormParams + * + * @return string + */ + public static function flattenORMParameters(array $ormParams): string + { + $flatParams = []; + foreach ($ormParams as $key => $value) { + $flatParams[] = $key . '=' . $value; + } + + return implode(', ', $flatParams); + } + + /** + * Generate PHP code for current doctrine field. + * + * @return string + */ + public function getField(): string + { + return $this->getFieldAnnotation() . + (new AttributeListGenerator( + $this->getFieldAttributes($this->isExcludingFieldFromJmsSerialization()) + ) + )->generate(4) . PHP_EOL . + $this->getFieldDeclaration() . + $this->getFieldGetter() . + $this->getFieldAlternativeGetter() . + $this->getFieldSetter() . PHP_EOL; + } + + /** + * @return array + */ + protected function getFieldAutodoc(): array + { + $docs = [ + $this->field->getLabel() . '.', + ]; + if (!empty($this->field->getDescription())) { + $docs[] = $this->field->getDescription() . '.'; + } + if (!empty($this->field->getDefaultValues())) { + $docs[] = 'Default values: ' . preg_replace( + "#(?:\\r\\n|\\n)#", + PHP_EOL . " * ", + $this->field->getDefaultValues() + ); + } + if (!empty($this->field->getGroupName())) { + $docs[] = 'Group: ' . $this->field->getGroupName() . '.'; + } + + return array_map(function ($line) { + return (!empty(trim($line))) ? (' ' . $line) : ($line); + }, $docs); + } + + /** + * @return string + */ + protected function getFieldAnnotation(): string + { + $autodoc = ''; + $fieldAutoDoc = $this->getFieldAutodoc(); + if (!empty($fieldAutoDoc)) { + $autodoc = PHP_EOL . + static::ANNOTATION_PREFIX . + implode(PHP_EOL . static::ANNOTATION_PREFIX, $fieldAutoDoc); + } + return ' + /**' . $autodoc . ' + * + * (Virtual field, this var is a buffer) + */' . PHP_EOL; + } + + /** + * Generate PHP property declaration block. + */ + protected function getFieldDeclaration(): string + { + $type = $this->getFieldTypeDeclaration(); + if (!empty($type)) { + $type .= ' '; + } + $defaultValue = $this->getFieldDefaultValueDeclaration(); + if (!empty($defaultValue)) { + $defaultValue = ' = ' . $defaultValue; + } + /* + * Buffer var to get referenced entities (documents, nodes, custom-forms, doctrine entities) + */ + return static::TAB . 'private ' . $type . '$' . $this->field->getVarName() . $defaultValue . ';' . PHP_EOL; + } + + protected function getFieldTypeDeclaration(): string + { + return ''; + } + + protected function toPhpDocType(string $typeHint): string + { + $unicode = new UnicodeString($typeHint); + return $unicode->startsWith('?') ? + $unicode->trimStart('?')->append('|null')->toString() : + $typeHint; + } + + protected function getFieldDefaultValueDeclaration(): string + { + return ''; + } + + /** + * @return array + */ + protected function getFieldAttributes(bool $exclude = false): array + { + $attributes = []; + + if ($exclude) { + $attributes[] = new AttributeGenerator('Serializer\Exclude'); + } + /* + * Symfony serializer is using getter / setter by default + */ + if (!$this->excludeFromSerialization()) { + $attributes[] = new AttributeGenerator('SymfonySerializer\SerializedName', [ + 'serializedName' => AttributeGenerator::wrapString($this->field->getVarName()) + ]); + $attributes[] = new AttributeGenerator('SymfonySerializer\Groups', [ + $this->getSerializationGroups() + ]); + if ($this->getSerializationMaxDepth() > 0) { + $attributes[] = new AttributeGenerator('SymfonySerializer\MaxDepth', [ + $this->getSerializationMaxDepth() + ]); + } + } + + if ( + $this->field->isIndexed() && + $this->options['use_api_platform_filters'] === true + ) { + switch (true) { + case $this->field->isString(): + $attributes[] = new AttributeGenerator('ApiFilter', [ + 0 => 'OrmFilter\SearchFilter::class', + 'strategy' => AttributeGenerator::wrapString('partial') + ]); + $attributes[] = new AttributeGenerator('ApiFilter', [ + 0 => '\RZ\Roadiz\CoreBundle\Api\Filter\NotFilter::class' + ]); + break; + case $this->field->isMultiple(): + case $this->field->isEnum(): + $attributes[] = new AttributeGenerator('ApiFilter', [ + 0 => 'OrmFilter\SearchFilter::class', + 'strategy' => AttributeGenerator::wrapString('exact') + ]); + $attributes[] = new AttributeGenerator('ApiFilter', [ + 0 => '\RZ\Roadiz\CoreBundle\Api\Filter\NotFilter::class' + ]); + break; + case $this->field->isBool(): + $attributes[] = new AttributeGenerator('ApiFilter', [ + 'OrmFilter\OrderFilter::class', + ]); + $attributes[] = new AttributeGenerator('ApiFilter', [ + 'OrmFilter\BooleanFilter::class', + ]); + break; + case $this->field->isManyToOne(): + case $this->field->isManyToMany(): + $attributes[] = new AttributeGenerator('ApiFilter', [ + 'OrmFilter\ExistsFilter::class', + ]); + break; + case $this->field->isInteger(): + case $this->field->isDecimal(): + $attributes[] = new AttributeGenerator('ApiFilter', [ + 'OrmFilter\OrderFilter::class', + ]); + $attributes[] = new AttributeGenerator('ApiFilter', [ + 'OrmFilter\NumericFilter::class', + ]); + $attributes[] = new AttributeGenerator('ApiFilter', [ + 'OrmFilter\RangeFilter::class', + ]); + break; + case $this->field->isDate(): + case $this->field->isDateTime(): + $attributes[] = new AttributeGenerator('ApiFilter', [ + 'OrmFilter\OrderFilter::class', + ]); + $attributes[] = new AttributeGenerator('ApiFilter', [ + 'OrmFilter\DateFilter::class', + ]); + break; + } + } + + return $attributes; + } + + /** + * Generate PHP alternative getter method block. + * + * @return string + */ + abstract protected function getFieldGetter(): string; + + /** + * Generate PHP alternative getter method block. + * + * @return string + */ + protected function getFieldAlternativeGetter(): string + { + return ''; + } + + /** + * Generate PHP setter method block. + * + * @return string + */ + protected function getFieldSetter(): string + { + return ''; + } + + /** + * @return string + */ + public function getCloneStatements(): string + { + return ''; + } + + /** + * Generate PHP annotation block for Doctrine table indexes. + * + * @return AttributeGenerator|null + */ + public function getFieldIndex(): ?AttributeGenerator + { + return null; + } + + /** + * Generate PHP property initialization for class constructor. + * + * @return string + */ + public function getFieldConstructorInitialization(): string + { + return ''; + } + + /** + * @return bool + */ + protected function excludeFromSerialization(): bool + { + if ($this->field instanceof SerializableInterface) { + return $this->field->isExcludedFromSerialization(); + } + return false; + } + + protected function getSerializationExclusionExpression(): ?string + { + if ( + $this->field instanceof SerializableInterface && + null !== $this->field->getSerializationExclusionExpression() + ) { + return (new UnicodeString($this->field->getSerializationExclusionExpression())) + ->replace('"', '') + ->replace('\\', '') + ->trim() + ->toString(); + } + return null; + } + + protected function getSerializationMaxDepth(): int + { + if ($this->field instanceof SerializableInterface && $this->field->getSerializationMaxDepth() > 0) { + return $this->field->getSerializationMaxDepth(); + } + return 2; + } + + protected function getDefaultSerializationGroups(): array + { + return [ + 'nodes_sources', + 'nodes_sources_' . ($this->field->getGroupNameCanonical() ?: 'default') + ]; + } + + protected function getSerializationGroups(): string + { + if ($this->field instanceof SerializableInterface && !empty($this->field->getSerializationGroups())) { + $groups = $this->field->getSerializationGroups(); + } else { + $groups = $this->getDefaultSerializationGroups(); + } + return '[' . implode(', ', array_map(function (string $group) { + return '"' . (new UnicodeString($group)) + ->replaceMatches('/[^A-Za-z0-9]++/', '_') + ->trim('_')->toString() . '"'; + }, $groups)) . ']'; + } + + /** + * @return AttributeGenerator[] + */ + protected function getSerializationAttributes(): array + { + if ($this->excludeFromSerialization()) { + return [ + new AttributeGenerator('Serializer\Exclude'), + new AttributeGenerator('SymfonySerializer\Ignore'), + ]; + } + $attributes = []; + $attributes[] = new AttributeGenerator('Serializer\Groups', [ + $this->getSerializationGroups() + ]); + + if ($this->getSerializationMaxDepth() > 0) { + $attributes[] = new AttributeGenerator('Serializer\MaxDepth', [ + $this->getSerializationMaxDepth() + ]); + } + + if (null !== $this->getSerializationExclusionExpression()) { + $attributes[] = new AttributeGenerator('Serializer\Exclude', [ + 'if' => AttributeGenerator::wrapString($this->getSerializationExclusionExpression()) + ]); + } + + switch (true) { + case $this->field->isBool(): + $attributes[] = new AttributeGenerator('Serializer\Type', [ + AttributeGenerator::wrapString('bool') + ]); + break; + case $this->field->isInteger(): + $attributes[] = new AttributeGenerator('Serializer\Type', [ + AttributeGenerator::wrapString('int') + ]); + break; + case $this->field->isDecimal(): + $attributes[] = new AttributeGenerator('Serializer\Type', [ + AttributeGenerator::wrapString('double') + ]); + break; + case $this->field->isColor(): + case $this->field->isEmail(): + case $this->field->isString(): + case $this->field->isCountry(): + case $this->field->isMarkdown(): + case $this->field->isText(): + case $this->field->isRichText(): + case $this->field->isEnum(): + $attributes[] = new AttributeGenerator('Serializer\Type', [ + AttributeGenerator::wrapString('string') + ]); + break; + case $this->field->isDateTime(): + case $this->field->isDate(): + $attributes[] = new AttributeGenerator('Serializer\Type', [ + AttributeGenerator::wrapString('DateTime') + ]); + break; + } + + return $attributes; + } + + protected function isExcludingFieldFromJmsSerialization(): bool + { + return true; + } +} diff --git a/lib/EntityGenerator/src/Field/CollectionFieldGenerator.php b/lib/EntityGenerator/src/Field/CollectionFieldGenerator.php new file mode 100644 index 00000000..ae82fe35 --- /dev/null +++ b/lib/EntityGenerator/src/Field/CollectionFieldGenerator.php @@ -0,0 +1,9 @@ +field->getVarName()) + ]); + + return $attributes; + } + + protected function getDefaultSerializationGroups(): array + { + $groups = parent::getDefaultSerializationGroups(); + $groups[] = 'nodes_sources_custom_forms'; + return $groups; + } + + protected function getFieldTypeDeclaration(): string + { + return '?array'; + } + + protected function getFieldDefaultValueDeclaration(): string + { + return 'null'; + } + + /** + * @inheritDoc + */ + public function getFieldGetter(): string + { + return ' + /** + * @return ' . $this->options['custom_form_class'] . '[] CustomForm array + */ +' . (new AttributeListGenerator($this->getSerializationAttributes()))->generate(4) . ' + public function ' . $this->field->getGetterName() . '(): array + { + if (null === $this->' . $this->field->getVarName() . ') { + if ( + null !== $this->objectManager && + null !== $this->getNode() && + null !== $this->getNode()->getNodeType() + ) { + $this->' . $this->field->getVarName() . ' = $this->objectManager + ->getRepository(' . $this->options['custom_form_class'] . '::class) + ->findByNodeAndField( + $this->getNode(), + $this->getNode()->getNodeType()->getFieldByName("' . $this->field->getName() . '") + ); + } else { + $this->' . $this->field->getVarName() . ' = []; + } + } + return $this->' . $this->field->getVarName() . '; + }' . PHP_EOL; + } + + /** + * Generate PHP setter method block. + * + * @return string + */ + protected function getFieldSetter(): string + { + return ' + /** + * @param ' . $this->options['custom_form_class'] . ' $customForm + * + * @return $this + */ + public function add' . ucfirst($this->field->getVarName()) . '(' . $this->options['custom_form_class'] . ' $customForm): static + { + if ( + null !== $this->objectManager && + null !== $this->getNode() && + null !== $this->getNode()->getNodeType() + ) { + $field = $this->getNode()->getNodeType()->getFieldByName("' . $this->field->getName() . '"); + if (null !== $field) { + $nodeCustomForm = new ' . $this->options['custom_form_proxy_class'] . '( + $this->getNode(), + $customForm, + $field + ); + $this->objectManager->persist($nodeCustomForm); + $this->getNode()->addCustomForm($nodeCustomForm); + $this->' . $this->field->getVarName() . ' = null; + } + } + return $this; + }' . PHP_EOL; + } +} diff --git a/lib/EntityGenerator/src/Field/DocumentsFieldGenerator.php b/lib/EntityGenerator/src/Field/DocumentsFieldGenerator.php new file mode 100644 index 00000000..6e85f133 --- /dev/null +++ b/lib/EntityGenerator/src/Field/DocumentsFieldGenerator.php @@ -0,0 +1,117 @@ +field->getVarName()) + ]); + $annotations[] = new AttributeGenerator('Serializer\Type', [ + AttributeGenerator::wrapString( + 'array<' . + (new UnicodeString($this->options['document_class']))->trimStart('\\')->toString() . + '>' + ) + ]); + + return $annotations; + } + + protected function getDefaultSerializationGroups(): array + { + $groups = parent::getDefaultSerializationGroups(); + $groups[] = 'nodes_sources_documents'; + return $groups; + } + + protected function getFieldTypeDeclaration(): string + { + return '?array'; + } + + protected function getFieldDefaultValueDeclaration(): string + { + return 'null'; + } + + /** + * @inheritDoc + */ + public function getFieldGetter(): string + { + return ' + /** + * @return ' . $this->options['document_class'] . '[] Documents array + */ +' . (new AttributeListGenerator($this->getSerializationAttributes()))->generate(4) . ' + public function ' . $this->field->getGetterName() . '(): array + { + if (null === $this->' . $this->field->getVarName() . ') { + if ( + null !== $this->objectManager && + null !== $this->getNode() && + null !== $this->getNode()->getNodeType() + ) { + $this->' . $this->field->getVarName() . ' = $this->objectManager + ->getRepository(' . $this->options['document_class'] . '::class) + ->findByNodeSourceAndField( + $this, + $this->getNode()->getNodeType()->getFieldByName("' . $this->field->getName() . '") + ); + } else { + $this->' . $this->field->getVarName() . ' = []; + } + } + return $this->' . $this->field->getVarName() . '; + }' . PHP_EOL; + } + + /** + * Generate PHP setter method block. + * + * @return string + */ + protected function getFieldSetter(): string + { + return ' + /** + * @param ' . $this->options['document_class'] . ' $document + * + * @return $this + */ + public function add' . ucfirst($this->field->getVarName()) . '(' . $this->options['document_class'] . ' $document): static + { + if ( + null !== $this->objectManager && + null !== $this->getNode() && + null !== $this->getNode()->getNodeType() + ) { + $field = $this->getNode()->getNodeType()->getFieldByName("' . $this->field->getName() . '"); + if (null !== $field) { + $nodeSourceDocument = new ' . $this->options['document_proxy_class'] . '( + $this, + $document, + $field + ); + if (!$this->hasNodesSourcesDocuments($nodeSourceDocument)) { + $this->objectManager->persist($nodeSourceDocument); + $this->addDocumentsByFields($nodeSourceDocument); + $this->' . $this->field->getVarName() . ' = null; + } + } + } + return $this; + }' . PHP_EOL; + } +} diff --git a/lib/EntityGenerator/src/Field/ManyToManyFieldGenerator.php b/lib/EntityGenerator/src/Field/ManyToManyFieldGenerator.php new file mode 100644 index 00000000..0c4a7f02 --- /dev/null +++ b/lib/EntityGenerator/src/Field/ManyToManyFieldGenerator.php @@ -0,0 +1,144 @@ +field->getNodeTypeName())) + ->ascii() + ->snake() + ->lower() + ->trim('-') + ->trim('_') + ->trim() + ->toString() + ; + $entityB = $this->field->getName(); + $joinColumnParams = [ + 'name' => AttributeGenerator::wrapString($entityA . '_id'), + 'referencedColumnName' => AttributeGenerator::wrapString('id'), + 'onDelete' => AttributeGenerator::wrapString('CASCADE') + ]; + $inverseJoinColumns = [ + 'name' => AttributeGenerator::wrapString($entityB . '_id'), + 'referencedColumnName' => AttributeGenerator::wrapString('id'), + 'onDelete' => AttributeGenerator::wrapString('CASCADE') + ]; + + $attributes[] = new AttributeGenerator('ORM\ManyToMany', [ + 'targetEntity' => $this->getFullyQualifiedClassName() . '::class' + ]); + $attributes[] = new AttributeGenerator('ORM\JoinTable', [ + 'name' => AttributeGenerator::wrapString($entityA . '_' . $entityB) + ]); + $attributes[] = new AttributeGenerator('ORM\JoinColumn', $joinColumnParams); + $attributes[] = new AttributeGenerator('ORM\InverseJoinColumn', $inverseJoinColumns); + if (count($this->configuration['orderBy']) > 0) { + // use default order for Collections + $orderBy = []; + foreach ($this->configuration['orderBy'] as $order) { + $orderBy[] = AttributeGenerator::wrapString($order['field']) . + ' => ' . + AttributeGenerator::wrapString($order['direction']); + } + $attributes[] = new AttributeGenerator('ORM\OrderBy', [ + 0 => '[' . implode(', ', $orderBy) . ']' + ]); + } + + if ($this->options['use_api_platform_filters'] === true) { + $attributes[] = new AttributeGenerator('ApiFilter', [ + 0 => 'OrmFilter\SearchFilter::class', + 'strategy' => AttributeGenerator::wrapString('exact') + ]); + } + + return [ + ...$attributes, + ...$this->getSerializationAttributes() + ]; + } + + /** + * @inheritDoc + */ + public function getFieldAnnotation(): string + { + return ' + /** + *' . implode(PHP_EOL . static::ANNOTATION_PREFIX, $this->getFieldAutodoc()) . ' + * @var Collection<' . $this->getFullyQualifiedClassName() . '> + */' . PHP_EOL; + } + + protected function getFieldTypeDeclaration(): string + { + return 'Collection'; + } + + /** + * @inheritDoc + */ + public function getFieldGetter(): string + { + return ' + /** + * @return Collection<' . $this->getFullyQualifiedClassName() . '> + */ + public function ' . $this->field->getGetterName() . '(): Collection + { + return $this->' . $this->field->getVarName() . '; + }' . PHP_EOL; + } + + /** + * @inheritDoc + */ + public function getFieldSetter(): string + { + return ' + /** + * @param Collection<' . $this->getFullyQualifiedClassName() . '>|' . $this->getFullyQualifiedClassName() . '[] $' . $this->field->getVarName() . ' + * @return $this + */ + public function ' . $this->field->getSetterName() . '(Collection|array $' . $this->field->getVarName() . '): static + { + if ($' . $this->field->getVarName() . ' instanceof \Doctrine\Common\Collections\Collection) { + $this->' . $this->field->getVarName() . ' = $' . $this->field->getVarName() . '; + } else { + $this->' . $this->field->getVarName() . ' = new \Doctrine\Common\Collections\ArrayCollection($' . $this->field->getVarName() . '); + } + + return $this; + }' . PHP_EOL; + } + + protected function isExcludingFieldFromJmsSerialization(): bool + { + return false; + } + + /** + * @inheritDoc + */ + public function getFieldConstructorInitialization(): string + { + return '$this->' . $this->field->getVarName() . ' = new \Doctrine\Common\Collections\ArrayCollection();'; + } +} diff --git a/lib/EntityGenerator/src/Field/ManyToOneFieldGenerator.php b/lib/EntityGenerator/src/Field/ManyToOneFieldGenerator.php new file mode 100644 index 00000000..44cd42ef --- /dev/null +++ b/lib/EntityGenerator/src/Field/ManyToOneFieldGenerator.php @@ -0,0 +1,102 @@ + AttributeGenerator::wrapString($this->field->getName() . '_id'), + 'referencedColumnName' => AttributeGenerator::wrapString('id'), + 'onDelete' => AttributeGenerator::wrapString('SET NULL'), + ]; + $attributes[] = new AttributeGenerator('ORM\ManyToOne', [ + 'targetEntity' => $this->getFullyQualifiedClassName() . '::class' + ]); + $attributes[] = new AttributeGenerator('ORM\JoinColumn', $ormParams); + + if ($this->options['use_api_platform_filters'] === true) { + $attributes[] = new AttributeGenerator('ApiFilter', [ + 0 => 'OrmFilter\SearchFilter::class', + 'strategy' => AttributeGenerator::wrapString('exact') + ]); + } + + return [ + ...$attributes, + ...$this->getSerializationAttributes() + ]; + } + + protected function isExcludingFieldFromJmsSerialization(): bool + { + return false; + } + + /** + * @inheritDoc + */ + public function getFieldAnnotation(): string + { + return ' + /** + *' . implode(PHP_EOL . static::ANNOTATION_PREFIX, $this->getFieldAutodoc()) . ' + * @var ' . $this->getFullyQualifiedClassName() . '|null + */' . PHP_EOL; + } + + protected function getFieldTypeDeclaration(): string + { + return '?' . $this->getFullyQualifiedClassName(); + } + + protected function getFieldDefaultValueDeclaration(): string + { + return 'null'; + } + + /** + * @inheritDoc + */ + public function getFieldGetter(): string + { + return ' + /** + * @return ' . $this->getFullyQualifiedClassName() . '|null + */ + public function ' . $this->field->getGetterName() . '(): ?' . $this->getFullyQualifiedClassName() . ' + { + return $this->' . $this->field->getVarName() . '; + }' . PHP_EOL; + } + + /** + * @inheritDoc + */ + public function getFieldSetter(): string + { + return ' + /** + * @param ' . $this->getFullyQualifiedClassName() . '|null $' . $this->field->getVarName() . ' + * @return $this + */ + public function ' . $this->field->getSetterName() . '(?' . $this->getFullyQualifiedClassName() . ' $' . $this->field->getVarName() . ' = null): static + { + $this->' . $this->field->getVarName() . ' = $' . $this->field->getVarName() . '; + + return $this; + }' . PHP_EOL; + } +} diff --git a/lib/EntityGenerator/src/Field/NodesFieldGenerator.php b/lib/EntityGenerator/src/Field/NodesFieldGenerator.php new file mode 100644 index 00000000..07866f74 --- /dev/null +++ b/lib/EntityGenerator/src/Field/NodesFieldGenerator.php @@ -0,0 +1,171 @@ +nodeTypeResolver = $nodeTypeResolver; + } + + /** + * Generate PHP code for current doctrine field. + * + * @return string + */ + public function getField(): string + { + return $this->getFieldGetter() . + $this->getFieldAlternativeGetter() . + $this->getFieldSetter() . PHP_EOL; + } + + protected function getSerializationAttributes(): array + { + $annotations = parent::getSerializationAttributes(); + $annotations[] = new AttributeGenerator('Serializer\VirtualProperty'); + $annotations[] = new AttributeGenerator('Serializer\SerializedName', [ + AttributeGenerator::wrapString($this->field->getVarName()) + ]); + $annotations[] = new AttributeGenerator('Serializer\Type', [ + AttributeGenerator::wrapString( + 'array<' . + (new UnicodeString($this->options['parent_class']))->trimStart('\\')->toString() . + '>' + ) + ]); + + return $annotations; + } + + protected function getDefaultSerializationGroups(): array + { + $groups = parent::getDefaultSerializationGroups(); + $groups[] = 'nodes_sources_nodes'; + return $groups; + } + + /** + * @return string + */ + protected function getFieldSourcesName(): string + { + return $this->field->getVarName() . 'Sources'; + } + /** + * @return bool + */ + protected function hasOnlyOneNodeType(): bool + { + if (!empty($this->field->getDefaultValues())) { + return count(explode(',', $this->field->getDefaultValues())) === 1; + } + return false; + } + + /** + * @return string + */ + protected function getRepositoryClass(): string + { + if (!empty($this->field->getDefaultValues()) && $this->hasOnlyOneNodeType() === true) { + $nodeTypeName = trim(explode(',', $this->field->getDefaultValues())[0]); + + $nodeType = $this->nodeTypeResolver->get($nodeTypeName); + if (null !== $nodeType) { + $className = $nodeType->getSourceEntityFullQualifiedClassName(); + return (new UnicodeString($className))->startsWith('\\') ? + $className : + '\\' . $className; + } + } + return $this->options['parent_class']; + } + + /** + * @inheritDoc + */ + public function getFieldGetter(): string + { + $autodoc = ''; + $fieldAutoDoc = $this->getFieldAutodoc(); + if (!empty($fieldAutoDoc)) { + $autodoc = PHP_EOL . + static::ANNOTATION_PREFIX . + implode(PHP_EOL . static::ANNOTATION_PREFIX, $fieldAutoDoc); + } + + return ' + /** + * ' . $this->getFieldSourcesName() . ' NodesSources direct field buffer. + * (Virtual field, this var is a buffer) + *' . $autodoc . ' + * @var ' . $this->getRepositoryClass() . '[]|null + */ +' . (new AttributeListGenerator( + $this->getFieldAttributes($this->isExcludingFieldFromJmsSerialization()) + ))->generate(4) . ' + private ?array $' . $this->getFieldSourcesName() . ' = null; + + /** + * @return ' . $this->getRepositoryClass() . '[] ' . $this->field->getVarName() . ' nodes-sources array + */ +' . (new AttributeListGenerator($this->getSerializationAttributes()))->generate(4) . ' + public function ' . $this->field->getGetterName() . 'Sources(): array + { + if (null === $this->' . $this->getFieldSourcesName() . ') { + if ( + null !== $this->objectManager && + null !== $this->getNode() && + null !== $this->getNode()->getNodeType() + ) { + $this->' . $this->getFieldSourcesName() . ' = $this->objectManager + ->getRepository(' . $this->getRepositoryClass() . '::class) + ->findByNodesSourcesAndFieldAndTranslation( + $this, + $this->getNode()->getNodeType()->getFieldByName("' . $this->field->getName() . '") + ); + } else { + $this->' . $this->getFieldSourcesName() . ' = []; + } + } + return $this->' . $this->getFieldSourcesName() . '; + }' . PHP_EOL; + } + + /** + * @inheritDoc + */ + public function getFieldSetter(): string + { + return ' + /** + * @param ' . $this->getRepositoryClass() . '[]|null $' . $this->getFieldSourcesName() . ' + * + * @return $this + */ + public function ' . $this->field->getSetterName() . 'Sources(?array $' . $this->getFieldSourcesName() . '): static + { + $this->' . $this->getFieldSourcesName() . ' = $' . $this->getFieldSourcesName() . '; + + return $this; + }' . PHP_EOL; + } +} diff --git a/lib/EntityGenerator/src/Field/NonVirtualFieldGenerator.php b/lib/EntityGenerator/src/Field/NonVirtualFieldGenerator.php new file mode 100644 index 00000000..46735cf2 --- /dev/null +++ b/lib/EntityGenerator/src/Field/NonVirtualFieldGenerator.php @@ -0,0 +1,258 @@ +field->isIndexed()) { + return new AttributeGenerator('ORM\Index', [ + 'columns' => '[' . AttributeGenerator::wrapString($this->field->getName()) . ']' + ]); + } + + return null; + } + + /** + * @return string + */ + protected function getDoctrineType(): string + { + return $this->field->getDoctrineType(); + } + + /** + * @return int|null String field length, returns NULL if length is irrelevant. + */ + protected function getFieldLength(): ?int + { + /* + * Only set length for string (VARCHAR) type + */ + if ($this->getDoctrineType() !== 'string') { + return null; + } + switch (true) { + case $this->field->isColor(): + return 10; + case $this->field->isCountry(): + return 5; + case $this->field->isPassword(): + case $this->field->isGeoTag(): + return 128; + default: + return 250; + } + } + + protected function isExcludingFieldFromJmsSerialization(): bool + { + return false; + } + + protected function getFieldAttributes(bool $exclude = false): array + { + $attributes = parent::getFieldAttributes($exclude); + + /* + * ?string $name = null, + * ?string $type = null, + * ?int $length = null, + * ?int $precision = null, + * ?int $scale = null, + * bool $unique = false, + * bool $nullable = false, + * bool $insertable = true, + * bool $updatable = true, + * ?string $enumType = null, + * array $options = [], + * ?string $columnDefinition = null, + * ?string $generated = null + */ + $ormParams = [ + 'name' => AttributeGenerator::wrapString($this->field->getName()), + 'type' => AttributeGenerator::wrapString($this->getDoctrineType()), + 'nullable' => 'true', + ]; + + $fieldLength = $this->getFieldLength(); + if (null !== $fieldLength && $fieldLength > 0) { + $ormParams['length'] = $fieldLength; + } + + if ($this->field->isDecimal()) { + $ormParams['precision'] = 18; + $ormParams['scale'] = 3; + } elseif ($this->field->isBool()) { + $ormParams['nullable'] = 'false'; + $ormParams['options'] = '["default" => false]'; + } + + $attributes[] = new AttributeGenerator('Gedmo\Versioned'); + $attributes[] = new AttributeGenerator('ORM\Column', $ormParams); + + if (empty($this->getFieldAlternativeGetter()) && !empty($this->getSerializationAttributes())) { + return [ + ...$attributes, + ...$this->getSerializationAttributes() + ]; + } + + return $attributes; + } + + + /** + * @inheritDoc + */ + public function getFieldAnnotation(): string + { + $autodoc = ''; + if (!empty($this->getFieldAutodoc())) { + $autodoc = PHP_EOL . + static::ANNOTATION_PREFIX . + implode(PHP_EOL . static::ANNOTATION_PREFIX, $this->getFieldAutodoc()); + } + + return ' + /**' . $autodoc . ' + */' . PHP_EOL; + } + + protected function getFieldTypeDeclaration(): string + { + switch (true) { + case $this->field->isBool(): + return 'bool'; + case $this->field->isMultiple(): + return '?array'; + case $this->field->isInteger(): + case $this->field->isDecimal(): + return 'int|float|null'; + case $this->field->isColor(): + case $this->field->isEmail(): + case $this->field->isString(): + case $this->field->isCountry(): + case $this->field->isMarkdown(): + case $this->field->isText(): + case $this->field->isRichText(): + case $this->field->isEnum(): + return '?string'; + case $this->field->isDateTime(): + case $this->field->isDate(): + return '?\DateTime'; + default: + return ''; + } + } + + protected function getFieldDefaultValueDeclaration(): string + { + switch (true) { + case $this->field->isBool(): + return 'false'; + default: + return 'null'; + } + } + + /** + * @inheritDoc + */ + public function getFieldGetter(): string + { + $type = $this->getFieldTypeDeclaration(); + if (empty($type)) { + $docType = 'mixed'; + $typeHint = ''; + } else { + $docType = $this->toPhpDocType($type); + $typeHint = ': ' . $type; + } + $assignation = '$this->' . $this->field->getVarName(); + + if ($this->field->isMultiple()) { + $assignation = sprintf('null !== %s ? array_values(%s) : null', $assignation, $assignation); + } + + return ' + /** + * @return ' . $docType . ' + */ + public function ' . $this->field->getGetterName() . '()' . $typeHint . ' + { + return ' . $assignation . '; + }' . PHP_EOL; + } + + /** + * @inheritDoc + */ + public function getFieldSetter(): string + { + $assignation = '$' . $this->field->getVarName(); + $nullable = true; + $casting = ''; + + switch (true) { + case $this->field->isBool(): + $casting = '(boolean) '; + $nullable = false; + break; + case $this->field->isInteger(): + $casting = '(int) '; + break; + case $this->field->isColor(): + case $this->field->isEmail(): + case $this->field->isString(): + case $this->field->isCountry(): + case $this->field->isMarkdown(): + case $this->field->isText(): + case $this->field->isRichText(): + case $this->field->isEnum(): + $casting = '(string) '; + break; + } + + $type = $this->getFieldTypeDeclaration(); + if (empty($type)) { + $docType = 'mixed'; + $typeHint = ''; + } else { + $docType = $this->toPhpDocType($type); + $typeHint = $type . ' '; + } + + if ($nullable && !empty($casting)) { + $assignation = '$this->' . $this->field->getVarName() . ' = null !== $' . $this->field->getVarName() . ' ? + ' . $casting . $assignation . ' : + null;'; + } else { + $assignation = '$this->' . $this->field->getVarName() . ' = ' . $assignation . ';'; + } + + return ' + /** + * @param ' . $docType . ' $' . $this->field->getVarName() . ' + * + * @return $this + */ + public function ' . $this->field->getSetterName() . '(' . $typeHint . '$' . $this->field->getVarName() . '): static + { + ' . $assignation . ' + + return $this; + }' . PHP_EOL; + } +} diff --git a/lib/EntityGenerator/src/Field/ProxiedManyToManyFieldGenerator.php b/lib/EntityGenerator/src/Field/ProxiedManyToManyFieldGenerator.php new file mode 100644 index 00000000..3738e891 --- /dev/null +++ b/lib/EntityGenerator/src/Field/ProxiedManyToManyFieldGenerator.php @@ -0,0 +1,249 @@ +excludeFromSerialization()) { + $annotations[] = new AttributeGenerator('Serializer\VirtualProperty'); + $annotations[] = new AttributeGenerator('Serializer\SerializedName', [ + AttributeGenerator::wrapString($this->field->getVarName()) + ]); + $annotations[] = new AttributeGenerator('SymfonySerializer\SerializedName', [ + 'serializedName' => AttributeGenerator::wrapString($this->field->getVarName()) + ]); + $annotations[] = new AttributeGenerator('SymfonySerializer\Groups', [ + $this->getSerializationGroups() + ]); + if ($this->getSerializationMaxDepth() > 0) { + $annotations[] = new AttributeGenerator('SymfonySerializer\MaxDepth', [ + $this->getSerializationMaxDepth() + ]); + } + } + return $annotations; + } + + /** + * Generate PHP property declaration block. + */ + protected function getFieldDeclaration(): string + { + /* + * Buffer var to get referenced entities (documents, nodes, cforms, doctrine entities) + */ + return ' private Collection $' . $this->getProxiedVarName() . ';' . PHP_EOL; + } + + protected function getFieldAttributes(bool $exclude = false): array + { + $attributes = []; + + $attributes[] = new AttributeGenerator('Serializer\Exclude'); + $attributes[] = new AttributeGenerator('SymfonySerializer\Ignore'); + + /* + * Many Users have Many Groups. + * @ManyToMany(targetEntity="Group") + * @JoinTable(name="users_groups", + * joinColumns={@JoinColumn(name="user_id", referencedColumnName="id")}, + * inverseJoinColumns={@JoinColumn(name="group_id", referencedColumnName="id")} + */ + $ormParams = [ + 'targetEntity' => '\\' . trim($this->getProxyClassname(), '\\') . '::class', + 'mappedBy' => AttributeGenerator::wrapString($this->configuration['proxy']['self']), + 'orphanRemoval' => 'true', + 'cascade' => '["persist", "remove"]' + ]; + + $attributes[] = new AttributeGenerator('ORM\OneToMany', $ormParams); + + if (isset($this->configuration['proxy']['orderBy']) && count($this->configuration['proxy']['orderBy']) > 0) { + // use default order for Collections + $orderBy = []; + foreach ($this->configuration['proxy']['orderBy'] as $order) { + $orderBy[] = AttributeGenerator::wrapString($order['field']) . + ' => ' . + AttributeGenerator::wrapString($order['direction']); + } + $attributes[] = new AttributeGenerator('ORM\OrderBy', [ + 0 => '[' . implode(', ', $orderBy) . ']' + ]); + } + + return $attributes; + } + + + /** + * @inheritDoc + */ + public function getFieldAnnotation(): string + { + return ' + /** + * ' . $this->field->getLabel() . ' + * + * @var Collection<' . $this->getProxyClassname() . '> + */' . PHP_EOL; + } + + /** + * @inheritDoc + */ + public function getFieldGetter(): string + { + return ' + /** + * @return Collection<' . $this->getProxyClassname() . '> + */ + public function ' . $this->getProxiedGetterName() . '(): Collection + { + return $this->' . $this->getProxiedVarName() . '; + } + + /** + * @return Collection + */ +' . (new AttributeListGenerator($this->getSerializationAttributes()))->generate(4) . ' + public function ' . $this->field->getGetterName() . '(): Collection + { + return $this->' . $this->getProxiedVarName() . '->map(function (' . $this->getProxyClassname() . ' $proxyEntity) { + return $proxyEntity->' . $this->getProxyRelationGetterName() . '(); + }); + }' . PHP_EOL; + } + + /** + * @inheritDoc + */ + public function getFieldSetter(): string + { + return ' + /** + * @param Collection $' . $this->getProxiedVarName() . ' + * @Serializer\VirtualProperty() + * @return $this + */ + public function ' . $this->getProxiedSetterName() . '(Collection $' . $this->getProxiedVarName() . '): static + { + $this->' . $this->getProxiedVarName() . ' = $' . $this->getProxiedVarName() . '; + + return $this; + } + /** + * @param Collection|array|null $' . $this->field->getVarName() . ' + * @return $this + */ + public function ' . $this->field->getSetterName() . '(Collection|array|null $' . $this->field->getVarName() . ' = null): static + { + foreach ($this->' . $this->getProxiedGetterName() . '() as $item) { + $item->' . $this->getProxySelfSetterName() . '(null); + } + $this->' . $this->getProxiedVarName() . '->clear(); + if (null !== $' . $this->field->getVarName() . ') { + $position = 0; + foreach ($' . $this->field->getVarName() . ' as $single' . ucwords($this->field->getVarName()) . ') { + $proxyEntity = new ' . $this->getProxyClassname() . '(); + $proxyEntity->' . $this->getProxySelfSetterName() . '($this); + if ($proxyEntity instanceof \RZ\Roadiz\Core\AbstractEntities\PositionedInterface) { + $proxyEntity->setPosition(++$position); + } + $proxyEntity->' . $this->getProxyRelationSetterName() . '($single' . ucwords($this->field->getVarName()) . '); + $this->' . $this->getProxiedVarName() . '->add($proxyEntity); + $this->objectManager->persist($proxyEntity); + } + } + + return $this; + }' . PHP_EOL; + } + + /** + * @inheritDoc + */ + public function getFieldConstructorInitialization(): string + { + return '$this->' . $this->getProxiedVarName() . ' = new \Doctrine\Common\Collections\ArrayCollection();'; + } + + /** + * @return string + */ + protected function getProxiedVarName(): string + { + return $this->field->getVarName() . 'Proxy'; + } + /** + * @return string + */ + protected function getProxiedSetterName(): string + { + return $this->field->getSetterName() . 'Proxy'; + } + /** + * @return string + */ + protected function getProxiedGetterName(): string + { + return $this->field->getGetterName() . 'Proxy'; + } + /** + * @return string + */ + protected function getProxySelfSetterName(): string + { + return 'set' . ucwords($this->configuration['proxy']['self']); + } + /** + * @return string + */ + protected function getProxyRelationSetterName(): string + { + return 'set' . ucwords($this->configuration['proxy']['relation']); + } + /** + * @return string + */ + protected function getProxyRelationGetterName(): string + { + return 'get' . ucwords($this->configuration['proxy']['relation']); + } + + /** + * @return string + */ + protected function getProxyClassname(): string + { + return (new UnicodeString($this->configuration['proxy']['classname']))->startsWith('\\') ? + $this->configuration['proxy']['classname'] : + '\\' . $this->configuration['proxy']['classname']; + } + + /** + * @return string + */ + public function getCloneStatements(): string + { + return ' + + $' . $this->getProxiedVarName() . 'Clone = new \Doctrine\Common\Collections\ArrayCollection(); + foreach ($this->' . $this->getProxiedVarName() . ' as $item) { + $itemClone = clone $item; + $itemClone->setNodeSource($this); + $' . $this->getProxiedVarName() . 'Clone->add($itemClone); + $this->objectManager->persist($itemClone); + } + $this->' . $this->getProxiedVarName() . ' = $' . $this->getProxiedVarName() . 'Clone; + '; + } +} diff --git a/lib/EntityGenerator/src/Field/YamlFieldGenerator.php b/lib/EntityGenerator/src/Field/YamlFieldGenerator.php new file mode 100644 index 00000000..c8c381d6 --- /dev/null +++ b/lib/EntityGenerator/src/Field/YamlFieldGenerator.php @@ -0,0 +1,66 @@ +excludeFromSerialization()) { + $annotations[] = new AttributeGenerator('Serializer\VirtualProperty'); + $annotations[] = new AttributeGenerator('Serializer\SerializedName', [ + AttributeGenerator::wrapString($this->field->getVarName()) + ]); + $annotations[] = new AttributeGenerator('SymfonySerializer\SerializedName', [ + 'serializedName' => AttributeGenerator::wrapString($this->field->getVarName()) + ]); + $annotations[] = new AttributeGenerator('SymfonySerializer\Groups', [ + $this->getSerializationGroups() + ]); + if ($this->getSerializationMaxDepth() > 0) { + $annotations[] = new AttributeGenerator('SymfonySerializer\MaxDepth', [ + $this->getSerializationMaxDepth() + ]); + } + } + return $annotations; + } + + protected function getDefaultSerializationGroups(): array + { + $groups = parent::getDefaultSerializationGroups(); + $groups[] = 'nodes_sources_yaml'; + return $groups; + } + + protected function isExcludingFieldFromJmsSerialization(): bool + { + return false; + } + + /** + * @return string + */ + public function getFieldAlternativeGetter(): string + { + $assignation = '$this->' . $this->field->getVarName(); + return ' + /** + * @return object|array|null + */ +' . (new AttributeListGenerator($this->getSerializationAttributes()))->generate(4) . ' + public function ' . $this->field->getGetterName() . 'AsObject() + { + if (null !== ' . $assignation . ') { + return \Symfony\Component\Yaml\Yaml::parse(' . $assignation . '); + } + return null; + }' . PHP_EOL; + } +} diff --git a/lib/EntityGenerator/src/RepositoryGenerator.php b/lib/EntityGenerator/src/RepositoryGenerator.php new file mode 100644 index 00000000..8c279759 --- /dev/null +++ b/lib/EntityGenerator/src/RepositoryGenerator.php @@ -0,0 +1,118 @@ +configureOptions($resolver); + + $this->nodeType = $nodeType; + $this->options = $resolver->resolve($options); + } + + public function getClassContent(): string + { + return $this->getClassHeader() . PHP_EOL . + $this->getClassBody(); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setRequired([ + 'parent_class', + 'entity_namespace', + 'namespace', + 'class_name', + ]); + $resolver->setAllowedTypes('parent_class', 'string'); + $resolver->setAllowedTypes('entity_namespace', 'string'); + $resolver->setAllowedTypes('namespace', 'string'); + $resolver->setAllowedTypes('class_name', 'string'); + + $normalizeClassName = function (OptionsResolver $resolver, string $className) { + return (new UnicodeString($className))->startsWith('\\') ? + $className : + '\\' . $className; + }; + + $resolver->setNormalizer('parent_class', $normalizeClassName); + $resolver->setNormalizer('entity_namespace', $normalizeClassName); + $resolver->setNormalizer('namespace', $normalizeClassName); + } + + /** + * @return string + */ + protected function getClassBody(): string + { + return 'class ' . $this->options['class_name'] . ' extends ' . $this->options['parent_class'] . ' +{' . $this->getClassConstructor() . '}' . PHP_EOL; + } + + /** + * @return string + */ + protected function getClassHeader(): string + { + $fqcn = $this->options['entity_namespace'] . '\\' . $this->nodeType->getSourceEntityClassName(); + /* + * BE CAREFUL, USE statements are required for field generators which + * are using ::class syntax! + */ + return 'options['namespace'], '\\') . '; + +use Doctrine\Persistence\ManagerRegistry; +use RZ\Roadiz\CoreBundle\Preview\PreviewResolverInterface; +use RZ\Roadiz\CoreBundle\SearchEngine\NodeSourceSearchHandlerInterface; +use Symfony\Component\Security\Core\Security; +use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; + +/** + * @extends ' . $this->options['parent_class'] . '<' . $fqcn . '> + * + * @method ' . $fqcn . '|null find($id, $lockMode = null, $lockVersion = null) + * @method ' . $fqcn . '|null findOneBy(array $criteria, array $orderBy = null) + * @method ' . $fqcn . '[] findAll() + * @method ' . $fqcn . '[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) + */'; + } + + /** + * @return string + */ + protected function getClassConstructor(): string + { + return ' + public function __construct( + ManagerRegistry $registry, + PreviewResolverInterface $previewResolver, + EventDispatcherInterface $dispatcher, + Security $security, + ?NodeSourceSearchHandlerInterface $nodeSourceSearchHandler + ) { + parent::__construct($registry, $previewResolver, $dispatcher, $security, $nodeSourceSearchHandler); + + $this->_entityName = ' . $this->options['entity_namespace'] . '\\' . $this->nodeType->getSourceEntityClassName() . '::class; + }' . PHP_EOL; + } +} diff --git a/lib/EntityGenerator/src/RepositoryGeneratorInterface.php b/lib/EntityGenerator/src/RepositoryGeneratorInterface.php new file mode 100644 index 00000000..87222427 --- /dev/null +++ b/lib/EntityGenerator/src/RepositoryGeneratorInterface.php @@ -0,0 +1,12 @@ +fooDatetime; + } + + /** + * @param \DateTime|null $fooDatetime + * + * @return $this + */ + public function setFooDatetime(?\DateTime $fooDatetime): static + { + $this->fooDatetime = $fooDatetime; + + return $this; + } + + + /** + * Foo field. + * Maecenas sed diam eget risus varius blandit sit amet non magna. + */ + #[ + SymfonySerializer\SerializedName(serializedName: "foo"), + SymfonySerializer\Groups(["nodes_sources", "nodes_sources_default"]), + SymfonySerializer\MaxDepth(1), + Gedmo\Versioned, + ORM\Column( + name: "foo", + type: "string", + nullable: true, + length: 250 + ), + Serializer\Groups(["nodes_sources", "nodes_sources_default"]), + Serializer\MaxDepth(1), + Serializer\Type("string") + ] + private ?string $foo = null; + + /** + * @return string|null + */ + public function getFoo(): ?string + { + return $this->foo; + } + + /** + * @param string|null $foo + * + * @return $this + */ + public function setFoo(?string $foo): static + { + $this->foo = null !== $foo ? + (string) $foo : + null; + + return $this; + } + + + /** + * Foo indexed field. + * Maecenas sed diam eget risus varius blandit sit amet non magna. + */ + #[ + SymfonySerializer\SerializedName(serializedName: "fooIndexed"), + SymfonySerializer\Groups(["nodes_sources", "nodes_sources_default"]), + SymfonySerializer\MaxDepth(1), + ApiFilter(OrmFilter\SearchFilter::class, strategy: "partial"), + ApiFilter(\RZ\Roadiz\CoreBundle\Api\Filter\NotFilter::class), + Gedmo\Versioned, + ORM\Column( + name: "fooIndexed", + type: "string", + nullable: true, + length: 250 + ), + Serializer\Groups(["nodes_sources", "nodes_sources_default"]), + Serializer\MaxDepth(1), + Serializer\Type("string") + ] + private ?string $fooIndexed = null; + + /** + * @return string|null + */ + public function getFooIndexed(): ?string + { + return $this->fooIndexed; + } + + /** + * @param string|null $fooIndexed + * + * @return $this + */ + public function setFooIndexed(?string $fooIndexed): static + { + $this->fooIndexed = null !== $fooIndexed ? + (string) $fooIndexed : + null; + + return $this; + } + + + /** + * Bool indexed field. + * Maecenas sed diam eget risus varius blandit sit amet non magna. + */ + #[ + SymfonySerializer\SerializedName(serializedName: "boolIndexed"), + SymfonySerializer\Groups(["nodes_sources", "nodes_sources_default"]), + SymfonySerializer\MaxDepth(1), + ApiFilter(OrmFilter\OrderFilter::class), + ApiFilter(OrmFilter\BooleanFilter::class), + Gedmo\Versioned, + ORM\Column( + name: "boolIndexed", + type: "boolean", + nullable: false, + options: ["default" => false] + ), + Serializer\Groups(["nodes_sources", "nodes_sources_default"]), + Serializer\MaxDepth(1), + Serializer\Type("bool") + ] + private bool $boolIndexed = false; + + /** + * @return bool + */ + public function getBoolIndexed(): bool + { + return $this->boolIndexed; + } + + /** + * @param bool $boolIndexed + * + * @return $this + */ + public function setBoolIndexed(bool $boolIndexed): static + { + $this->boolIndexed = $boolIndexed; + + return $this; + } + + + /** + * Foo markdown field. + * Maecenas sed diam eget risus varius blandit sit amet non magna. + * Default values: allow_h2: false + * allow_h3: false + * allow_h4: false + * allow_h5: false + * allow_h6: false + * allow_bold: true + * allow_italic: true + * allow_blockquote: false + * allow_image: false + * allow_list: false + * allow_nbsp: true + * allow_nb_hyphen: true + * allow_return: true + * allow_link: false + * allow_hr: false + * allow_preview: true + */ + #[ + SymfonySerializer\SerializedName(serializedName: "fooMarkdown"), + SymfonySerializer\Groups(["nodes_sources", "nodes_sources_default"]), + SymfonySerializer\MaxDepth(1), + Gedmo\Versioned, + ORM\Column(name: "foo_markdown", type: "text", nullable: true), + Serializer\Groups(["nodes_sources", "nodes_sources_default"]), + Serializer\MaxDepth(1), + Serializer\Type("string") + ] + private ?string $fooMarkdown = null; + + /** + * @return string|null + */ + public function getFooMarkdown(): ?string + { + return $this->fooMarkdown; + } + + /** + * @param string|null $fooMarkdown + * + * @return $this + */ + public function setFooMarkdown(?string $fooMarkdown): static + { + $this->fooMarkdown = null !== $fooMarkdown ? + (string) $fooMarkdown : + null; + + return $this; + } + + + /** + * Foo excluded markdown field. + * Maecenas sed diam eget risus varius blandit sit amet non magna. + * Default values: allow_h2: false + * allow_h3: false + * allow_h4: false + * allow_h5: false + * allow_h6: false + * allow_bold: true + * allow_italic: true + * allow_blockquote: false + * allow_image: false + * allow_list: false + * allow_nbsp: true + * allow_nb_hyphen: true + * allow_return: true + * allow_link: false + * allow_hr: false + * allow_preview: true + */ + #[ + Gedmo\Versioned, + ORM\Column(name: "foo_markdown_excluded", type: "text", nullable: true), + Serializer\Exclude, + SymfonySerializer\Ignore + ] + private ?string $fooMarkdownExcluded = null; + + /** + * @return string|null + */ + public function getFooMarkdownExcluded(): ?string + { + return $this->fooMarkdownExcluded; + } + + /** + * @param string|null $fooMarkdownExcluded + * + * @return $this + */ + public function setFooMarkdownExcluded(?string $fooMarkdownExcluded): static + { + $this->fooMarkdownExcluded = null !== $fooMarkdownExcluded ? + (string) $fooMarkdownExcluded : + null; + + return $this; + } + + + /** + * Foo expression excluded decimal. + * Maecenas sed diam eget risus varius blandit sit amet non magna. + */ + #[ + SymfonySerializer\SerializedName(serializedName: "fooDecimalExcluded"), + SymfonySerializer\Groups(["nodes_sources", "nodes_sources_default"]), + SymfonySerializer\MaxDepth(2), + ApiFilter(OrmFilter\OrderFilter::class), + ApiFilter(OrmFilter\NumericFilter::class), + ApiFilter(OrmFilter\RangeFilter::class), + Gedmo\Versioned, + ORM\Column( + name: "foo_decimal_excluded", + type: "decimal", + nullable: true, + precision: 18, + scale: 3 + ), + Serializer\Groups(["nodes_sources", "nodes_sources_default"]), + Serializer\MaxDepth(2), + Serializer\Exclude(if: "object.foo == 'test'"), + Serializer\Type("double") + ] + private int|float|null $fooDecimalExcluded = null; + + /** + * @return int|float|null + */ + public function getFooDecimalExcluded(): int|float|null + { + return $this->fooDecimalExcluded; + } + + /** + * @param int|float|null $fooDecimalExcluded + * + * @return $this + */ + public function setFooDecimalExcluded(int|float|null $fooDecimalExcluded): static + { + $this->fooDecimalExcluded = $fooDecimalExcluded; + + return $this; + } + + + /** + * Référence à l'événement. + * Maecenas sed diam eget risus varius blandit sit amet non magna. + * Default values: # Entity class name + * classname: \App\Entity\Base\Event + * # Displayable is the method used to display entity name + * displayable: getName + * # Same as Displayable but for a secondary information + * alt_displayable: getSortingFirstDateTime + * # Same as Displayable but for a secondary information + * thumbnail: getMainDocument + * # Searchable entity fields + * searchable: + * - name + * - slug + * # This order will only be used for explorer + * orderBy: + * - field: sortingLastDateTime + * direction: DESC + * @var \App\Entity\Base\Event|null + */ + #[ + SymfonySerializer\SerializedName(serializedName: "singleEventReference"), + SymfonySerializer\Groups(["nodes_sources", "nodes_sources_default"]), + SymfonySerializer\MaxDepth(2), + ORM\ManyToOne(targetEntity: \App\Entity\Base\Event::class), + ORM\JoinColumn(name: "single_event_reference_id", referencedColumnName: "id", onDelete: "SET NULL"), + ApiFilter(OrmFilter\SearchFilter::class, strategy: "exact"), + Serializer\Groups(["nodes_sources", "nodes_sources_default"]), + Serializer\MaxDepth(2) + ] + private ?\App\Entity\Base\Event $singleEventReference = null; + + /** + * @return \App\Entity\Base\Event|null + */ + public function getSingleEventReference(): ?\App\Entity\Base\Event + { + return $this->singleEventReference; + } + + /** + * @param \App\Entity\Base\Event|null $singleEventReference + * @return $this + */ + public function setSingleEventReference(?\App\Entity\Base\Event $singleEventReference = null): static + { + $this->singleEventReference = $singleEventReference; + + return $this; + } + + + /** + * Remontée d'événements manuelle. + * Maecenas sed diam eget risus varius blandit sit amet non magna. + * Default values: # Entity class name + * classname: \App\Entity\Base\Event + * # Displayable is the method used to display entity name + * displayable: getName + * # Same as Displayable but for a secondary information + * alt_displayable: getSortingFirstDateTime + * # Same as Displayable but for a secondary information + * thumbnail: getMainDocument + * # Searchable entity fields + * searchable: + * - name + * - slug + * # This order will only be used for explorer + * orderBy: + * - field: sortingLastDateTime + * direction: DESC + * @var Collection<\App\Entity\Base\Event> + */ + #[ + SymfonySerializer\SerializedName(serializedName: "eventReferences"), + SymfonySerializer\Groups(["nodes_sources", "nodes_sources_default"]), + SymfonySerializer\MaxDepth(2), + ORM\ManyToMany(targetEntity: \App\Entity\Base\Event::class), + ORM\JoinTable(name: "node_type_event_references"), + ORM\JoinColumn(name: "node_type_id", referencedColumnName: "id", onDelete: "CASCADE"), + ORM\InverseJoinColumn(name: "event_references_id", referencedColumnName: "id", onDelete: "CASCADE"), + ORM\OrderBy(["sortingLastDateTime" => "DESC"]), + ApiFilter(OrmFilter\SearchFilter::class, strategy: "exact"), + Serializer\Groups(["nodes_sources", "nodes_sources_default"]), + Serializer\MaxDepth(2) + ] + private Collection $eventReferences; + + /** + * @return Collection<\App\Entity\Base\Event> + */ + public function getEventReferences(): Collection + { + return $this->eventReferences; + } + + /** + * @param Collection<\App\Entity\Base\Event>|\App\Entity\Base\Event[] $eventReferences + * @return $this + */ + public function setEventReferences(Collection|array $eventReferences): static + { + if ($eventReferences instanceof \Doctrine\Common\Collections\Collection) { + $this->eventReferences = $eventReferences; + } else { + $this->eventReferences = new \Doctrine\Common\Collections\ArrayCollection($eventReferences); + } + + return $this; + } + + + /** + * Remontée d'événements manuelle + * + * @var Collection<\App\Entity\PositionedCity> + */ + #[ + Serializer\Exclude, + SymfonySerializer\Ignore, + ORM\OneToMany( + targetEntity: \App\Entity\PositionedCity::class, + mappedBy: "nodeSource", + orphanRemoval: true, + cascade: ["persist", "remove"] + ), + ORM\OrderBy(["position" => "ASC"]) + ] + private Collection $eventReferencesProxiedProxy; + + /** + * @return Collection<\App\Entity\PositionedCity> + */ + public function getEventReferencesProxiedProxy(): Collection + { + return $this->eventReferencesProxiedProxy; + } + + /** + * @return Collection + */ + #[ + Serializer\Groups(["nodes_sources", "nodes_sources_default"]), + Serializer\MaxDepth(2), + Serializer\VirtualProperty, + Serializer\SerializedName("eventReferencesProxied"), + SymfonySerializer\SerializedName(serializedName: "eventReferencesProxied"), + SymfonySerializer\Groups(["nodes_sources", "nodes_sources_default"]), + SymfonySerializer\MaxDepth(2) + ] + public function getEventReferencesProxied(): Collection + { + return $this->eventReferencesProxiedProxy->map(function (\App\Entity\PositionedCity $proxyEntity) { + return $proxyEntity->getCity(); + }); + } + + /** + * @param Collection $eventReferencesProxiedProxy + * @Serializer\VirtualProperty() + * @return $this + */ + public function setEventReferencesProxiedProxy(Collection $eventReferencesProxiedProxy): static + { + $this->eventReferencesProxiedProxy = $eventReferencesProxiedProxy; + + return $this; + } + /** + * @param Collection|array|null $eventReferencesProxied + * @return $this + */ + public function setEventReferencesProxied(Collection|array|null $eventReferencesProxied = null): static + { + foreach ($this->getEventReferencesProxiedProxy() as $item) { + $item->setNodeSource(null); + } + $this->eventReferencesProxiedProxy->clear(); + if (null !== $eventReferencesProxied) { + $position = 0; + foreach ($eventReferencesProxied as $singleEventReferencesProxied) { + $proxyEntity = new \App\Entity\PositionedCity(); + $proxyEntity->setNodeSource($this); + if ($proxyEntity instanceof \RZ\Roadiz\Core\AbstractEntities\PositionedInterface) { + $proxyEntity->setPosition(++$position); + } + $proxyEntity->setCity($singleEventReferencesProxied); + $this->eventReferencesProxiedProxy->add($proxyEntity); + $this->objectManager->persist($proxyEntity); + } + } + + return $this; + } + + + /** + * Remontée d'événements manuelle exclue. + * Maecenas sed diam eget risus varius blandit sit amet non magna. + * Default values: # Entity class name + * classname: \App\Entity\Base\Event + * # Displayable is the method used to display entity name + * displayable: getName + * # Same as Displayable but for a secondary information + * alt_displayable: getSortingFirstDateTime + * # Same as Displayable but for a secondary information + * thumbnail: getMainDocument + * # Searchable entity fields + * searchable: + * - name + * - slug + * # This order will only be used for explorer + * orderBy: + * - field: sortingLastDateTime + * direction: DESC + * @var Collection<\App\Entity\Base\Event> + */ + #[ + ORM\ManyToMany(targetEntity: \App\Entity\Base\Event::class), + ORM\JoinTable(name: "node_type_event_references_excluded"), + ORM\JoinColumn(name: "node_type_id", referencedColumnName: "id", onDelete: "CASCADE"), + ORM\InverseJoinColumn(name: "event_references_excluded_id", referencedColumnName: "id", onDelete: "CASCADE"), + ORM\OrderBy(["sortingLastDateTime" => "DESC"]), + ApiFilter(OrmFilter\SearchFilter::class, strategy: "exact"), + Serializer\Exclude, + SymfonySerializer\Ignore + ] + private Collection $eventReferencesExcluded; + + /** + * @return Collection<\App\Entity\Base\Event> + */ + public function getEventReferencesExcluded(): Collection + { + return $this->eventReferencesExcluded; + } + + /** + * @param Collection<\App\Entity\Base\Event>|\App\Entity\Base\Event[] $eventReferencesExcluded + * @return $this + */ + public function setEventReferencesExcluded(Collection|array $eventReferencesExcluded): static + { + if ($eventReferencesExcluded instanceof \Doctrine\Common\Collections\Collection) { + $this->eventReferencesExcluded = $eventReferencesExcluded; + } else { + $this->eventReferencesExcluded = new \Doctrine\Common\Collections\ArrayCollection($eventReferencesExcluded); + } + + return $this; + } + + + /** + * Bar documents field. + * Maecenas sed diam eget risus varius blandit sit amet non magna. + * + * (Virtual field, this var is a buffer) + */ + #[ + Serializer\Exclude, + SymfonySerializer\SerializedName(serializedName: "bar"), + SymfonySerializer\Groups(["nodes_sources", "nodes_sources_default", "nodes_sources_documents"]), + SymfonySerializer\MaxDepth(1) + ] + private ?array $bar = null; + + /** + * @return \mock\Entity\Document[] Documents array + */ + #[ + Serializer\Groups(["nodes_sources", "nodes_sources_default", "nodes_sources_documents"]), + Serializer\MaxDepth(1), + Serializer\VirtualProperty, + Serializer\SerializedName("bar"), + Serializer\Type("array") + ] + public function getBar(): array + { + if (null === $this->bar) { + if ( + null !== $this->objectManager && + null !== $this->getNode() && + null !== $this->getNode()->getNodeType() + ) { + $this->bar = $this->objectManager + ->getRepository(\mock\Entity\Document::class) + ->findByNodeSourceAndField( + $this, + $this->getNode()->getNodeType()->getFieldByName("bar") + ); + } else { + $this->bar = []; + } + } + return $this->bar; + } + + /** + * @param \mock\Entity\Document $document + * + * @return $this + */ + public function addBar(\mock\Entity\Document $document): static + { + if ( + null !== $this->objectManager && + null !== $this->getNode() && + null !== $this->getNode()->getNodeType() + ) { + $field = $this->getNode()->getNodeType()->getFieldByName("bar"); + if (null !== $field) { + $nodeSourceDocument = new \mock\Entity\NodesSourcesDocument( + $this, + $document, + $field + ); + if (!$this->hasNodesSourcesDocuments($nodeSourceDocument)) { + $this->objectManager->persist($nodeSourceDocument); + $this->addDocumentsByFields($nodeSourceDocument); + $this->bar = null; + } + } + } + return $this; + } + + + /** + * Custom forms field. + * + * (Virtual field, this var is a buffer) + */ + #[ + Serializer\Exclude, + SymfonySerializer\SerializedName(serializedName: "theForms"), + SymfonySerializer\Groups(["nodes_sources", "nodes_sources_default", "nodes_sources_custom_forms"]), + SymfonySerializer\MaxDepth(2) + ] + private ?array $theForms = null; + + /** + * @return \mock\Entity\CustomForm[] CustomForm array + */ + #[ + Serializer\Groups(["nodes_sources", "nodes_sources_default", "nodes_sources_custom_forms"]), + Serializer\MaxDepth(2), + Serializer\VirtualProperty, + Serializer\SerializedName("theForms") + ] + public function getTheForms(): array + { + if (null === $this->theForms) { + if ( + null !== $this->objectManager && + null !== $this->getNode() && + null !== $this->getNode()->getNodeType() + ) { + $this->theForms = $this->objectManager + ->getRepository(\mock\Entity\CustomForm::class) + ->findByNodeAndField( + $this->getNode(), + $this->getNode()->getNodeType()->getFieldByName("the_forms") + ); + } else { + $this->theForms = []; + } + } + return $this->theForms; + } + + /** + * @param \mock\Entity\CustomForm $customForm + * + * @return $this + */ + public function addTheForms(\mock\Entity\CustomForm $customForm): static + { + if ( + null !== $this->objectManager && + null !== $this->getNode() && + null !== $this->getNode()->getNodeType() + ) { + $field = $this->getNode()->getNodeType()->getFieldByName("the_forms"); + if (null !== $field) { + $nodeCustomForm = new \mock\Entity\NodesSourcesCustomForm( + $this->getNode(), + $customForm, + $field + ); + $this->objectManager->persist($nodeCustomForm); + $this->getNode()->addCustomForm($nodeCustomForm); + $this->theForms = null; + } + } + return $this; + } + + + /** + * fooBarSources NodesSources direct field buffer. + * (Virtual field, this var is a buffer) + * + * ForBar nodes field. + * Maecenas sed diam eget risus varius blandit sit amet non magna. + * @var \mock\Entity\NodesSources[]|null + */ + #[ + Serializer\Exclude, + SymfonySerializer\SerializedName(serializedName: "fooBar"), + SymfonySerializer\Groups(["nodes_sources", "nodes_sources_default", "nodes_sources_nodes"]), + SymfonySerializer\MaxDepth(2) + ] + private ?array $fooBarSources = null; + + /** + * @return \mock\Entity\NodesSources[] fooBar nodes-sources array + */ + #[ + Serializer\Groups(["nodes_sources", "nodes_sources_default", "nodes_sources_nodes"]), + Serializer\MaxDepth(2), + Serializer\VirtualProperty, + Serializer\SerializedName("fooBar"), + Serializer\Type("array") + ] + public function getFooBarSources(): array + { + if (null === $this->fooBarSources) { + if ( + null !== $this->objectManager && + null !== $this->getNode() && + null !== $this->getNode()->getNodeType() + ) { + $this->fooBarSources = $this->objectManager + ->getRepository(\mock\Entity\NodesSources::class) + ->findByNodesSourcesAndFieldAndTranslation( + $this, + $this->getNode()->getNodeType()->getFieldByName("foo_bar") + ); + } else { + $this->fooBarSources = []; + } + } + return $this->fooBarSources; + } + + /** + * @param \mock\Entity\NodesSources[]|null $fooBarSources + * + * @return $this + */ + public function setFooBarSources(?array $fooBarSources): static + { + $this->fooBarSources = $fooBarSources; + + return $this; + } + + + /** + * fooBarTypedSources NodesSources direct field buffer. + * (Virtual field, this var is a buffer) + * + * ForBar nodes typed field. + * Default values: MockTwo + * @var \tests\mocks\GeneratedNodesSources\NSMockTwo[]|null + */ + #[ + Serializer\Exclude, + SymfonySerializer\SerializedName(serializedName: "fooBarTyped"), + SymfonySerializer\Groups(["nodes_sources", "nodes_sources_default", "nodes_sources_nodes"]), + SymfonySerializer\MaxDepth(2) + ] + private ?array $fooBarTypedSources = null; + + /** + * @return \tests\mocks\GeneratedNodesSources\NSMockTwo[] fooBarTyped nodes-sources array + */ + #[ + Serializer\Groups(["nodes_sources", "nodes_sources_default", "nodes_sources_nodes"]), + Serializer\MaxDepth(2), + Serializer\VirtualProperty, + Serializer\SerializedName("fooBarTyped"), + Serializer\Type("array") + ] + public function getFooBarTypedSources(): array + { + if (null === $this->fooBarTypedSources) { + if ( + null !== $this->objectManager && + null !== $this->getNode() && + null !== $this->getNode()->getNodeType() + ) { + $this->fooBarTypedSources = $this->objectManager + ->getRepository(\tests\mocks\GeneratedNodesSources\NSMockTwo::class) + ->findByNodesSourcesAndFieldAndTranslation( + $this, + $this->getNode()->getNodeType()->getFieldByName("foo_bar_typed") + ); + } else { + $this->fooBarTypedSources = []; + } + } + return $this->fooBarTypedSources; + } + + /** + * @param \tests\mocks\GeneratedNodesSources\NSMockTwo[]|null $fooBarTypedSources + * + * @return $this + */ + public function setFooBarTypedSources(?array $fooBarTypedSources): static + { + $this->fooBarTypedSources = $fooBarTypedSources; + + return $this; + } + + + /** + * For many_to_one field. + * Default values: classname: \MyCustomEntity + * displayable: getName + * @var \MyCustomEntity|null + */ + #[ + SymfonySerializer\SerializedName(serializedName: "fooManyToOne"), + SymfonySerializer\Groups(["nodes_sources", "nodes_sources_default"]), + SymfonySerializer\MaxDepth(2), + ORM\ManyToOne(targetEntity: \MyCustomEntity::class), + ORM\JoinColumn(name: "foo_many_to_one_id", referencedColumnName: "id", onDelete: "SET NULL"), + ApiFilter(OrmFilter\SearchFilter::class, strategy: "exact"), + Serializer\Groups(["nodes_sources", "nodes_sources_default"]), + Serializer\MaxDepth(2) + ] + private ?\MyCustomEntity $fooManyToOne = null; + + /** + * @return \MyCustomEntity|null + */ + public function getFooManyToOne(): ?\MyCustomEntity + { + return $this->fooManyToOne; + } + + /** + * @param \MyCustomEntity|null $fooManyToOne + * @return $this + */ + public function setFooManyToOne(?\MyCustomEntity $fooManyToOne = null): static + { + $this->fooManyToOne = $fooManyToOne; + + return $this; + } + + + /** + * For many_to_many field. + * Default values: classname: \MyCustomEntity + * displayable: getName + * orderBy: + * - field: name + * direction: asc + * @var Collection<\MyCustomEntity> + */ + #[ + SymfonySerializer\SerializedName(serializedName: "fooManyToMany"), + SymfonySerializer\Groups(["nodes_sources", "nodes_sources_default"]), + SymfonySerializer\MaxDepth(2), + ORM\ManyToMany(targetEntity: \MyCustomEntity::class), + ORM\JoinTable(name: "node_type_foo_many_to_many"), + ORM\JoinColumn(name: "node_type_id", referencedColumnName: "id", onDelete: "CASCADE"), + ORM\InverseJoinColumn(name: "foo_many_to_many_id", referencedColumnName: "id", onDelete: "CASCADE"), + ORM\OrderBy(["name" => "asc"]), + ApiFilter(OrmFilter\SearchFilter::class, strategy: "exact"), + Serializer\Groups(["nodes_sources", "nodes_sources_default"]), + Serializer\MaxDepth(2) + ] + private Collection $fooManyToMany; + + /** + * @return Collection<\MyCustomEntity> + */ + public function getFooManyToMany(): Collection + { + return $this->fooManyToMany; + } + + /** + * @param Collection<\MyCustomEntity>|\MyCustomEntity[] $fooManyToMany + * @return $this + */ + public function setFooManyToMany(Collection|array $fooManyToMany): static + { + if ($fooManyToMany instanceof \Doctrine\Common\Collections\Collection) { + $this->fooManyToMany = $fooManyToMany; + } else { + $this->fooManyToMany = new \Doctrine\Common\Collections\ArrayCollection($fooManyToMany); + } + + return $this; + } + + + /** + * For many_to_many proxied field + * + * @var Collection<\Themes\MyTheme\Entities\PositionedCity> + */ + #[ + Serializer\Exclude, + SymfonySerializer\Ignore, + ORM\OneToMany( + targetEntity: \Themes\MyTheme\Entities\PositionedCity::class, + mappedBy: "nodeSource", + orphanRemoval: true, + cascade: ["persist", "remove"] + ), + ORM\OrderBy(["position" => "ASC"]) + ] + private Collection $fooManyToManyProxiedProxy; + + /** + * @return Collection<\Themes\MyTheme\Entities\PositionedCity> + */ + public function getFooManyToManyProxiedProxy(): Collection + { + return $this->fooManyToManyProxiedProxy; + } + + /** + * @return Collection + */ + #[ + Serializer\Groups(["nodes_sources", "nodes_sources_default"]), + Serializer\MaxDepth(1), + Serializer\VirtualProperty, + Serializer\SerializedName("fooManyToManyProxied"), + SymfonySerializer\SerializedName(serializedName: "fooManyToManyProxied"), + SymfonySerializer\Groups(["nodes_sources", "nodes_sources_default"]), + SymfonySerializer\MaxDepth(1) + ] + public function getFooManyToManyProxied(): Collection + { + return $this->fooManyToManyProxiedProxy->map(function (\Themes\MyTheme\Entities\PositionedCity $proxyEntity) { + return $proxyEntity->getCity(); + }); + } + + /** + * @param Collection $fooManyToManyProxiedProxy + * @Serializer\VirtualProperty() + * @return $this + */ + public function setFooManyToManyProxiedProxy(Collection $fooManyToManyProxiedProxy): static + { + $this->fooManyToManyProxiedProxy = $fooManyToManyProxiedProxy; + + return $this; + } + /** + * @param Collection|array|null $fooManyToManyProxied + * @return $this + */ + public function setFooManyToManyProxied(Collection|array|null $fooManyToManyProxied = null): static + { + foreach ($this->getFooManyToManyProxiedProxy() as $item) { + $item->setNodeSource(null); + } + $this->fooManyToManyProxiedProxy->clear(); + if (null !== $fooManyToManyProxied) { + $position = 0; + foreach ($fooManyToManyProxied as $singleFooManyToManyProxied) { + $proxyEntity = new \Themes\MyTheme\Entities\PositionedCity(); + $proxyEntity->setNodeSource($this); + if ($proxyEntity instanceof \RZ\Roadiz\Core\AbstractEntities\PositionedInterface) { + $proxyEntity->setPosition(++$position); + } + $proxyEntity->setCity($singleFooManyToManyProxied); + $this->fooManyToManyProxiedProxy->add($proxyEntity); + $this->objectManager->persist($proxyEntity); + } + } + + return $this; + } + + + public function __construct(\mock\Entity\Node $node, \mock\Entity\Translation $translation) + { + parent::__construct($node, $translation); + + $this->eventReferences = new \Doctrine\Common\Collections\ArrayCollection(); + $this->eventReferencesProxiedProxy = new \Doctrine\Common\Collections\ArrayCollection(); + $this->eventReferencesExcluded = new \Doctrine\Common\Collections\ArrayCollection(); + $this->fooManyToMany = new \Doctrine\Common\Collections\ArrayCollection(); + $this->fooManyToManyProxiedProxy = new \Doctrine\Common\Collections\ArrayCollection(); + } + + #[ + Serializer\VirtualProperty, + Serializer\Groups(["nodes_sources", "nodes_sources_default"]), + Serializer\SerializedName("@type"), + SymfonySerializer\Groups(["nodes_sources", "nodes_sources_default"]), + SymfonySerializer\SerializedName(serializedName: "@type") + ] + public function getNodeTypeName(): string + { + return 'Mock'; + } + + /** + * $this->nodeType->isReachable() proxy. + * + * @return bool Does this nodeSource is reachable over network? + */ + public function isReachable(): bool + { + return true; + } + + /** + * $this->nodeType->isPublishable() proxy. + * + * @return bool Does this nodeSource is publishable with date and time? + */ + public function isPublishable(): bool + { + return true; + } + + public function __clone() + { + parent::__clone(); + + $eventReferencesProxiedProxyClone = new \Doctrine\Common\Collections\ArrayCollection(); + foreach ($this->eventReferencesProxiedProxy as $item) { + $itemClone = clone $item; + $itemClone->setNodeSource($this); + $eventReferencesProxiedProxyClone->add($itemClone); + $this->objectManager->persist($itemClone); + } + $this->eventReferencesProxiedProxy = $eventReferencesProxiedProxyClone; + + $fooManyToManyProxiedProxyClone = new \Doctrine\Common\Collections\ArrayCollection(); + foreach ($this->fooManyToManyProxiedProxy as $item) { + $itemClone = clone $item; + $itemClone->setNodeSource($this); + $fooManyToManyProxiedProxyClone->add($itemClone); + $this->objectManager->persist($itemClone); + } + $this->fooManyToManyProxiedProxy = $fooManyToManyProxiedProxyClone; + } + + public function __toString(): string + { + return '[NSMock] ' . parent::__toString(); + } +} diff --git a/lib/EntityGenerator/tests/mocks/GeneratedNodesSourcesWithRepository/NSMock.php b/lib/EntityGenerator/tests/mocks/GeneratedNodesSourcesWithRepository/NSMock.php new file mode 100644 index 00000000..47393856 --- /dev/null +++ b/lib/EntityGenerator/tests/mocks/GeneratedNodesSourcesWithRepository/NSMock.php @@ -0,0 +1,1121 @@ +fooDatetime; + } + + /** + * @param \DateTime|null $fooDatetime + * + * @return $this + */ + public function setFooDatetime(?\DateTime $fooDatetime): static + { + $this->fooDatetime = $fooDatetime; + + return $this; + } + + + /** + * Foo field. + * Maecenas sed diam eget risus varius blandit sit amet non magna. + */ + #[ + SymfonySerializer\SerializedName(serializedName: "foo"), + SymfonySerializer\Groups(["nodes_sources", "nodes_sources_default"]), + SymfonySerializer\MaxDepth(1), + Gedmo\Versioned, + ORM\Column( + name: "foo", + type: "string", + nullable: true, + length: 250 + ), + Serializer\Groups(["nodes_sources", "nodes_sources_default"]), + Serializer\MaxDepth(1), + Serializer\Type("string") + ] + private ?string $foo = null; + + /** + * @return string|null + */ + public function getFoo(): ?string + { + return $this->foo; + } + + /** + * @param string|null $foo + * + * @return $this + */ + public function setFoo(?string $foo): static + { + $this->foo = null !== $foo ? + (string) $foo : + null; + + return $this; + } + + + /** + * Foo indexed field. + * Maecenas sed diam eget risus varius blandit sit amet non magna. + */ + #[ + SymfonySerializer\SerializedName(serializedName: "fooIndexed"), + SymfonySerializer\Groups(["nodes_sources", "nodes_sources_default"]), + SymfonySerializer\MaxDepth(1), + ApiFilter(OrmFilter\SearchFilter::class, strategy: "partial"), + ApiFilter(\RZ\Roadiz\CoreBundle\Api\Filter\NotFilter::class), + Gedmo\Versioned, + ORM\Column( + name: "fooIndexed", + type: "string", + nullable: true, + length: 250 + ), + Serializer\Groups(["nodes_sources", "nodes_sources_default"]), + Serializer\MaxDepth(1), + Serializer\Type("string") + ] + private ?string $fooIndexed = null; + + /** + * @return string|null + */ + public function getFooIndexed(): ?string + { + return $this->fooIndexed; + } + + /** + * @param string|null $fooIndexed + * + * @return $this + */ + public function setFooIndexed(?string $fooIndexed): static + { + $this->fooIndexed = null !== $fooIndexed ? + (string) $fooIndexed : + null; + + return $this; + } + + + /** + * Bool indexed field. + * Maecenas sed diam eget risus varius blandit sit amet non magna. + */ + #[ + SymfonySerializer\SerializedName(serializedName: "boolIndexed"), + SymfonySerializer\Groups(["nodes_sources", "nodes_sources_default"]), + SymfonySerializer\MaxDepth(1), + ApiFilter(OrmFilter\OrderFilter::class), + ApiFilter(OrmFilter\BooleanFilter::class), + Gedmo\Versioned, + ORM\Column( + name: "boolIndexed", + type: "boolean", + nullable: false, + options: ["default" => false] + ), + Serializer\Groups(["nodes_sources", "nodes_sources_default"]), + Serializer\MaxDepth(1), + Serializer\Type("bool") + ] + private bool $boolIndexed = false; + + /** + * @return bool + */ + public function getBoolIndexed(): bool + { + return $this->boolIndexed; + } + + /** + * @param bool $boolIndexed + * + * @return $this + */ + public function setBoolIndexed(bool $boolIndexed): static + { + $this->boolIndexed = $boolIndexed; + + return $this; + } + + + /** + * Foo markdown field. + * Maecenas sed diam eget risus varius blandit sit amet non magna. + * Default values: allow_h2: false + * allow_h3: false + * allow_h4: false + * allow_h5: false + * allow_h6: false + * allow_bold: true + * allow_italic: true + * allow_blockquote: false + * allow_image: false + * allow_list: false + * allow_nbsp: true + * allow_nb_hyphen: true + * allow_return: true + * allow_link: false + * allow_hr: false + * allow_preview: true + */ + #[ + SymfonySerializer\SerializedName(serializedName: "fooMarkdown"), + SymfonySerializer\Groups(["nodes_sources", "nodes_sources_default"]), + SymfonySerializer\MaxDepth(1), + Gedmo\Versioned, + ORM\Column(name: "foo_markdown", type: "text", nullable: true), + Serializer\Groups(["nodes_sources", "nodes_sources_default"]), + Serializer\MaxDepth(1), + Serializer\Type("string") + ] + private ?string $fooMarkdown = null; + + /** + * @return string|null + */ + public function getFooMarkdown(): ?string + { + return $this->fooMarkdown; + } + + /** + * @param string|null $fooMarkdown + * + * @return $this + */ + public function setFooMarkdown(?string $fooMarkdown): static + { + $this->fooMarkdown = null !== $fooMarkdown ? + (string) $fooMarkdown : + null; + + return $this; + } + + + /** + * Foo excluded markdown field. + * Maecenas sed diam eget risus varius blandit sit amet non magna. + * Default values: allow_h2: false + * allow_h3: false + * allow_h4: false + * allow_h5: false + * allow_h6: false + * allow_bold: true + * allow_italic: true + * allow_blockquote: false + * allow_image: false + * allow_list: false + * allow_nbsp: true + * allow_nb_hyphen: true + * allow_return: true + * allow_link: false + * allow_hr: false + * allow_preview: true + */ + #[ + Gedmo\Versioned, + ORM\Column(name: "foo_markdown_excluded", type: "text", nullable: true), + Serializer\Exclude, + SymfonySerializer\Ignore + ] + private ?string $fooMarkdownExcluded = null; + + /** + * @return string|null + */ + public function getFooMarkdownExcluded(): ?string + { + return $this->fooMarkdownExcluded; + } + + /** + * @param string|null $fooMarkdownExcluded + * + * @return $this + */ + public function setFooMarkdownExcluded(?string $fooMarkdownExcluded): static + { + $this->fooMarkdownExcluded = null !== $fooMarkdownExcluded ? + (string) $fooMarkdownExcluded : + null; + + return $this; + } + + + /** + * Foo expression excluded decimal. + * Maecenas sed diam eget risus varius blandit sit amet non magna. + */ + #[ + SymfonySerializer\SerializedName(serializedName: "fooDecimalExcluded"), + SymfonySerializer\Groups(["nodes_sources", "nodes_sources_default"]), + SymfonySerializer\MaxDepth(2), + ApiFilter(OrmFilter\OrderFilter::class), + ApiFilter(OrmFilter\NumericFilter::class), + ApiFilter(OrmFilter\RangeFilter::class), + Gedmo\Versioned, + ORM\Column( + name: "foo_decimal_excluded", + type: "decimal", + nullable: true, + precision: 18, + scale: 3 + ), + Serializer\Groups(["nodes_sources", "nodes_sources_default"]), + Serializer\MaxDepth(2), + Serializer\Exclude(if: "object.foo == 'test'"), + Serializer\Type("double") + ] + private int|float|null $fooDecimalExcluded = null; + + /** + * @return int|float|null + */ + public function getFooDecimalExcluded(): int|float|null + { + return $this->fooDecimalExcluded; + } + + /** + * @param int|float|null $fooDecimalExcluded + * + * @return $this + */ + public function setFooDecimalExcluded(int|float|null $fooDecimalExcluded): static + { + $this->fooDecimalExcluded = $fooDecimalExcluded; + + return $this; + } + + + /** + * Référence à l'événement. + * Maecenas sed diam eget risus varius blandit sit amet non magna. + * Default values: # Entity class name + * classname: \App\Entity\Base\Event + * # Displayable is the method used to display entity name + * displayable: getName + * # Same as Displayable but for a secondary information + * alt_displayable: getSortingFirstDateTime + * # Same as Displayable but for a secondary information + * thumbnail: getMainDocument + * # Searchable entity fields + * searchable: + * - name + * - slug + * # This order will only be used for explorer + * orderBy: + * - field: sortingLastDateTime + * direction: DESC + * @var \App\Entity\Base\Event|null + */ + #[ + SymfonySerializer\SerializedName(serializedName: "singleEventReference"), + SymfonySerializer\Groups(["nodes_sources", "nodes_sources_default"]), + SymfonySerializer\MaxDepth(2), + ORM\ManyToOne(targetEntity: \App\Entity\Base\Event::class), + ORM\JoinColumn(name: "single_event_reference_id", referencedColumnName: "id", onDelete: "SET NULL"), + ApiFilter(OrmFilter\SearchFilter::class, strategy: "exact"), + Serializer\Groups(["nodes_sources", "nodes_sources_default"]), + Serializer\MaxDepth(2) + ] + private ?\App\Entity\Base\Event $singleEventReference = null; + + /** + * @return \App\Entity\Base\Event|null + */ + public function getSingleEventReference(): ?\App\Entity\Base\Event + { + return $this->singleEventReference; + } + + /** + * @param \App\Entity\Base\Event|null $singleEventReference + * @return $this + */ + public function setSingleEventReference(?\App\Entity\Base\Event $singleEventReference = null): static + { + $this->singleEventReference = $singleEventReference; + + return $this; + } + + + /** + * Remontée d'événements manuelle. + * Maecenas sed diam eget risus varius blandit sit amet non magna. + * Default values: # Entity class name + * classname: \App\Entity\Base\Event + * # Displayable is the method used to display entity name + * displayable: getName + * # Same as Displayable but for a secondary information + * alt_displayable: getSortingFirstDateTime + * # Same as Displayable but for a secondary information + * thumbnail: getMainDocument + * # Searchable entity fields + * searchable: + * - name + * - slug + * # This order will only be used for explorer + * orderBy: + * - field: sortingLastDateTime + * direction: DESC + * @var Collection<\App\Entity\Base\Event> + */ + #[ + SymfonySerializer\SerializedName(serializedName: "eventReferences"), + SymfonySerializer\Groups(["nodes_sources", "nodes_sources_default"]), + SymfonySerializer\MaxDepth(2), + ORM\ManyToMany(targetEntity: \App\Entity\Base\Event::class), + ORM\JoinTable(name: "node_type_event_references"), + ORM\JoinColumn(name: "node_type_id", referencedColumnName: "id", onDelete: "CASCADE"), + ORM\InverseJoinColumn(name: "event_references_id", referencedColumnName: "id", onDelete: "CASCADE"), + ORM\OrderBy(["sortingLastDateTime" => "DESC"]), + ApiFilter(OrmFilter\SearchFilter::class, strategy: "exact"), + Serializer\Groups(["nodes_sources", "nodes_sources_default"]), + Serializer\MaxDepth(2) + ] + private Collection $eventReferences; + + /** + * @return Collection<\App\Entity\Base\Event> + */ + public function getEventReferences(): Collection + { + return $this->eventReferences; + } + + /** + * @param Collection<\App\Entity\Base\Event>|\App\Entity\Base\Event[] $eventReferences + * @return $this + */ + public function setEventReferences(Collection|array $eventReferences): static + { + if ($eventReferences instanceof \Doctrine\Common\Collections\Collection) { + $this->eventReferences = $eventReferences; + } else { + $this->eventReferences = new \Doctrine\Common\Collections\ArrayCollection($eventReferences); + } + + return $this; + } + + + /** + * Remontée d'événements manuelle + * + * @var Collection<\App\Entity\PositionedCity> + */ + #[ + Serializer\Exclude, + SymfonySerializer\Ignore, + ORM\OneToMany( + targetEntity: \App\Entity\PositionedCity::class, + mappedBy: "nodeSource", + orphanRemoval: true, + cascade: ["persist", "remove"] + ), + ORM\OrderBy(["position" => "ASC"]) + ] + private Collection $eventReferencesProxiedProxy; + + /** + * @return Collection<\App\Entity\PositionedCity> + */ + public function getEventReferencesProxiedProxy(): Collection + { + return $this->eventReferencesProxiedProxy; + } + + /** + * @return Collection + */ + #[ + Serializer\Groups(["nodes_sources", "nodes_sources_default"]), + Serializer\MaxDepth(2), + Serializer\VirtualProperty, + Serializer\SerializedName("eventReferencesProxied"), + SymfonySerializer\SerializedName(serializedName: "eventReferencesProxied"), + SymfonySerializer\Groups(["nodes_sources", "nodes_sources_default"]), + SymfonySerializer\MaxDepth(2) + ] + public function getEventReferencesProxied(): Collection + { + return $this->eventReferencesProxiedProxy->map(function (\App\Entity\PositionedCity $proxyEntity) { + return $proxyEntity->getCity(); + }); + } + + /** + * @param Collection $eventReferencesProxiedProxy + * @Serializer\VirtualProperty() + * @return $this + */ + public function setEventReferencesProxiedProxy(Collection $eventReferencesProxiedProxy): static + { + $this->eventReferencesProxiedProxy = $eventReferencesProxiedProxy; + + return $this; + } + /** + * @param Collection|array|null $eventReferencesProxied + * @return $this + */ + public function setEventReferencesProxied(Collection|array|null $eventReferencesProxied = null): static + { + foreach ($this->getEventReferencesProxiedProxy() as $item) { + $item->setNodeSource(null); + } + $this->eventReferencesProxiedProxy->clear(); + if (null !== $eventReferencesProxied) { + $position = 0; + foreach ($eventReferencesProxied as $singleEventReferencesProxied) { + $proxyEntity = new \App\Entity\PositionedCity(); + $proxyEntity->setNodeSource($this); + if ($proxyEntity instanceof \RZ\Roadiz\Core\AbstractEntities\PositionedInterface) { + $proxyEntity->setPosition(++$position); + } + $proxyEntity->setCity($singleEventReferencesProxied); + $this->eventReferencesProxiedProxy->add($proxyEntity); + $this->objectManager->persist($proxyEntity); + } + } + + return $this; + } + + + /** + * Remontée d'événements manuelle exclue. + * Maecenas sed diam eget risus varius blandit sit amet non magna. + * Default values: # Entity class name + * classname: \App\Entity\Base\Event + * # Displayable is the method used to display entity name + * displayable: getName + * # Same as Displayable but for a secondary information + * alt_displayable: getSortingFirstDateTime + * # Same as Displayable but for a secondary information + * thumbnail: getMainDocument + * # Searchable entity fields + * searchable: + * - name + * - slug + * # This order will only be used for explorer + * orderBy: + * - field: sortingLastDateTime + * direction: DESC + * @var Collection<\App\Entity\Base\Event> + */ + #[ + ORM\ManyToMany(targetEntity: \App\Entity\Base\Event::class), + ORM\JoinTable(name: "node_type_event_references_excluded"), + ORM\JoinColumn(name: "node_type_id", referencedColumnName: "id", onDelete: "CASCADE"), + ORM\InverseJoinColumn(name: "event_references_excluded_id", referencedColumnName: "id", onDelete: "CASCADE"), + ORM\OrderBy(["sortingLastDateTime" => "DESC"]), + ApiFilter(OrmFilter\SearchFilter::class, strategy: "exact"), + Serializer\Exclude, + SymfonySerializer\Ignore + ] + private Collection $eventReferencesExcluded; + + /** + * @return Collection<\App\Entity\Base\Event> + */ + public function getEventReferencesExcluded(): Collection + { + return $this->eventReferencesExcluded; + } + + /** + * @param Collection<\App\Entity\Base\Event>|\App\Entity\Base\Event[] $eventReferencesExcluded + * @return $this + */ + public function setEventReferencesExcluded(Collection|array $eventReferencesExcluded): static + { + if ($eventReferencesExcluded instanceof \Doctrine\Common\Collections\Collection) { + $this->eventReferencesExcluded = $eventReferencesExcluded; + } else { + $this->eventReferencesExcluded = new \Doctrine\Common\Collections\ArrayCollection($eventReferencesExcluded); + } + + return $this; + } + + + /** + * Bar documents field. + * Maecenas sed diam eget risus varius blandit sit amet non magna. + * + * (Virtual field, this var is a buffer) + */ + #[ + Serializer\Exclude, + SymfonySerializer\SerializedName(serializedName: "bar"), + SymfonySerializer\Groups(["nodes_sources", "nodes_sources_default", "nodes_sources_documents"]), + SymfonySerializer\MaxDepth(1) + ] + private ?array $bar = null; + + /** + * @return \mock\Entity\Document[] Documents array + */ + #[ + Serializer\Groups(["nodes_sources", "nodes_sources_default", "nodes_sources_documents"]), + Serializer\MaxDepth(1), + Serializer\VirtualProperty, + Serializer\SerializedName("bar"), + Serializer\Type("array") + ] + public function getBar(): array + { + if (null === $this->bar) { + if ( + null !== $this->objectManager && + null !== $this->getNode() && + null !== $this->getNode()->getNodeType() + ) { + $this->bar = $this->objectManager + ->getRepository(\mock\Entity\Document::class) + ->findByNodeSourceAndField( + $this, + $this->getNode()->getNodeType()->getFieldByName("bar") + ); + } else { + $this->bar = []; + } + } + return $this->bar; + } + + /** + * @param \mock\Entity\Document $document + * + * @return $this + */ + public function addBar(\mock\Entity\Document $document): static + { + if ( + null !== $this->objectManager && + null !== $this->getNode() && + null !== $this->getNode()->getNodeType() + ) { + $field = $this->getNode()->getNodeType()->getFieldByName("bar"); + if (null !== $field) { + $nodeSourceDocument = new \mock\Entity\NodesSourcesDocument( + $this, + $document, + $field + ); + if (!$this->hasNodesSourcesDocuments($nodeSourceDocument)) { + $this->objectManager->persist($nodeSourceDocument); + $this->addDocumentsByFields($nodeSourceDocument); + $this->bar = null; + } + } + } + return $this; + } + + + /** + * Custom forms field. + * + * (Virtual field, this var is a buffer) + */ + #[ + Serializer\Exclude, + SymfonySerializer\SerializedName(serializedName: "theForms"), + SymfonySerializer\Groups(["nodes_sources", "nodes_sources_default", "nodes_sources_custom_forms"]), + SymfonySerializer\MaxDepth(2) + ] + private ?array $theForms = null; + + /** + * @return \mock\Entity\CustomForm[] CustomForm array + */ + #[ + Serializer\Groups(["nodes_sources", "nodes_sources_default", "nodes_sources_custom_forms"]), + Serializer\MaxDepth(2), + Serializer\VirtualProperty, + Serializer\SerializedName("theForms") + ] + public function getTheForms(): array + { + if (null === $this->theForms) { + if ( + null !== $this->objectManager && + null !== $this->getNode() && + null !== $this->getNode()->getNodeType() + ) { + $this->theForms = $this->objectManager + ->getRepository(\mock\Entity\CustomForm::class) + ->findByNodeAndField( + $this->getNode(), + $this->getNode()->getNodeType()->getFieldByName("the_forms") + ); + } else { + $this->theForms = []; + } + } + return $this->theForms; + } + + /** + * @param \mock\Entity\CustomForm $customForm + * + * @return $this + */ + public function addTheForms(\mock\Entity\CustomForm $customForm): static + { + if ( + null !== $this->objectManager && + null !== $this->getNode() && + null !== $this->getNode()->getNodeType() + ) { + $field = $this->getNode()->getNodeType()->getFieldByName("the_forms"); + if (null !== $field) { + $nodeCustomForm = new \mock\Entity\NodesSourcesCustomForm( + $this->getNode(), + $customForm, + $field + ); + $this->objectManager->persist($nodeCustomForm); + $this->getNode()->addCustomForm($nodeCustomForm); + $this->theForms = null; + } + } + return $this; + } + + + /** + * fooBarSources NodesSources direct field buffer. + * (Virtual field, this var is a buffer) + * + * ForBar nodes field. + * Maecenas sed diam eget risus varius blandit sit amet non magna. + * @var \mock\Entity\NodesSources[]|null + */ + #[ + Serializer\Exclude, + SymfonySerializer\SerializedName(serializedName: "fooBar"), + SymfonySerializer\Groups(["nodes_sources", "nodes_sources_default", "nodes_sources_nodes"]), + SymfonySerializer\MaxDepth(2) + ] + private ?array $fooBarSources = null; + + /** + * @return \mock\Entity\NodesSources[] fooBar nodes-sources array + */ + #[ + Serializer\Groups(["nodes_sources", "nodes_sources_default", "nodes_sources_nodes"]), + Serializer\MaxDepth(2), + Serializer\VirtualProperty, + Serializer\SerializedName("fooBar"), + Serializer\Type("array") + ] + public function getFooBarSources(): array + { + if (null === $this->fooBarSources) { + if ( + null !== $this->objectManager && + null !== $this->getNode() && + null !== $this->getNode()->getNodeType() + ) { + $this->fooBarSources = $this->objectManager + ->getRepository(\mock\Entity\NodesSources::class) + ->findByNodesSourcesAndFieldAndTranslation( + $this, + $this->getNode()->getNodeType()->getFieldByName("foo_bar") + ); + } else { + $this->fooBarSources = []; + } + } + return $this->fooBarSources; + } + + /** + * @param \mock\Entity\NodesSources[]|null $fooBarSources + * + * @return $this + */ + public function setFooBarSources(?array $fooBarSources): static + { + $this->fooBarSources = $fooBarSources; + + return $this; + } + + + /** + * fooBarTypedSources NodesSources direct field buffer. + * (Virtual field, this var is a buffer) + * + * ForBar nodes typed field. + * Default values: MockTwo + * @var \tests\mocks\GeneratedNodesSources\NSMockTwo[]|null + */ + #[ + Serializer\Exclude, + SymfonySerializer\SerializedName(serializedName: "fooBarTyped"), + SymfonySerializer\Groups(["nodes_sources", "nodes_sources_default", "nodes_sources_nodes"]), + SymfonySerializer\MaxDepth(2) + ] + private ?array $fooBarTypedSources = null; + + /** + * @return \tests\mocks\GeneratedNodesSources\NSMockTwo[] fooBarTyped nodes-sources array + */ + #[ + Serializer\Groups(["nodes_sources", "nodes_sources_default", "nodes_sources_nodes"]), + Serializer\MaxDepth(2), + Serializer\VirtualProperty, + Serializer\SerializedName("fooBarTyped"), + Serializer\Type("array") + ] + public function getFooBarTypedSources(): array + { + if (null === $this->fooBarTypedSources) { + if ( + null !== $this->objectManager && + null !== $this->getNode() && + null !== $this->getNode()->getNodeType() + ) { + $this->fooBarTypedSources = $this->objectManager + ->getRepository(\tests\mocks\GeneratedNodesSources\NSMockTwo::class) + ->findByNodesSourcesAndFieldAndTranslation( + $this, + $this->getNode()->getNodeType()->getFieldByName("foo_bar_typed") + ); + } else { + $this->fooBarTypedSources = []; + } + } + return $this->fooBarTypedSources; + } + + /** + * @param \tests\mocks\GeneratedNodesSources\NSMockTwo[]|null $fooBarTypedSources + * + * @return $this + */ + public function setFooBarTypedSources(?array $fooBarTypedSources): static + { + $this->fooBarTypedSources = $fooBarTypedSources; + + return $this; + } + + + /** + * For many_to_one field. + * Default values: classname: \MyCustomEntity + * displayable: getName + * @var \MyCustomEntity|null + */ + #[ + SymfonySerializer\SerializedName(serializedName: "fooManyToOne"), + SymfonySerializer\Groups(["nodes_sources", "nodes_sources_default"]), + SymfonySerializer\MaxDepth(2), + ORM\ManyToOne(targetEntity: \MyCustomEntity::class), + ORM\JoinColumn(name: "foo_many_to_one_id", referencedColumnName: "id", onDelete: "SET NULL"), + ApiFilter(OrmFilter\SearchFilter::class, strategy: "exact"), + Serializer\Groups(["nodes_sources", "nodes_sources_default"]), + Serializer\MaxDepth(2) + ] + private ?\MyCustomEntity $fooManyToOne = null; + + /** + * @return \MyCustomEntity|null + */ + public function getFooManyToOne(): ?\MyCustomEntity + { + return $this->fooManyToOne; + } + + /** + * @param \MyCustomEntity|null $fooManyToOne + * @return $this + */ + public function setFooManyToOne(?\MyCustomEntity $fooManyToOne = null): static + { + $this->fooManyToOne = $fooManyToOne; + + return $this; + } + + + /** + * For many_to_many field. + * Default values: classname: \MyCustomEntity + * displayable: getName + * orderBy: + * - field: name + * direction: asc + * @var Collection<\MyCustomEntity> + */ + #[ + SymfonySerializer\SerializedName(serializedName: "fooManyToMany"), + SymfonySerializer\Groups(["nodes_sources", "nodes_sources_default"]), + SymfonySerializer\MaxDepth(2), + ORM\ManyToMany(targetEntity: \MyCustomEntity::class), + ORM\JoinTable(name: "node_type_foo_many_to_many"), + ORM\JoinColumn(name: "node_type_id", referencedColumnName: "id", onDelete: "CASCADE"), + ORM\InverseJoinColumn(name: "foo_many_to_many_id", referencedColumnName: "id", onDelete: "CASCADE"), + ORM\OrderBy(["name" => "asc"]), + ApiFilter(OrmFilter\SearchFilter::class, strategy: "exact"), + Serializer\Groups(["nodes_sources", "nodes_sources_default"]), + Serializer\MaxDepth(2) + ] + private Collection $fooManyToMany; + + /** + * @return Collection<\MyCustomEntity> + */ + public function getFooManyToMany(): Collection + { + return $this->fooManyToMany; + } + + /** + * @param Collection<\MyCustomEntity>|\MyCustomEntity[] $fooManyToMany + * @return $this + */ + public function setFooManyToMany(Collection|array $fooManyToMany): static + { + if ($fooManyToMany instanceof \Doctrine\Common\Collections\Collection) { + $this->fooManyToMany = $fooManyToMany; + } else { + $this->fooManyToMany = new \Doctrine\Common\Collections\ArrayCollection($fooManyToMany); + } + + return $this; + } + + + /** + * For many_to_many proxied field + * + * @var Collection<\Themes\MyTheme\Entities\PositionedCity> + */ + #[ + Serializer\Exclude, + SymfonySerializer\Ignore, + ORM\OneToMany( + targetEntity: \Themes\MyTheme\Entities\PositionedCity::class, + mappedBy: "nodeSource", + orphanRemoval: true, + cascade: ["persist", "remove"] + ), + ORM\OrderBy(["position" => "ASC"]) + ] + private Collection $fooManyToManyProxiedProxy; + + /** + * @return Collection<\Themes\MyTheme\Entities\PositionedCity> + */ + public function getFooManyToManyProxiedProxy(): Collection + { + return $this->fooManyToManyProxiedProxy; + } + + /** + * @return Collection + */ + #[ + Serializer\Groups(["nodes_sources", "nodes_sources_default"]), + Serializer\MaxDepth(1), + Serializer\VirtualProperty, + Serializer\SerializedName("fooManyToManyProxied"), + SymfonySerializer\SerializedName(serializedName: "fooManyToManyProxied"), + SymfonySerializer\Groups(["nodes_sources", "nodes_sources_default"]), + SymfonySerializer\MaxDepth(1) + ] + public function getFooManyToManyProxied(): Collection + { + return $this->fooManyToManyProxiedProxy->map(function (\Themes\MyTheme\Entities\PositionedCity $proxyEntity) { + return $proxyEntity->getCity(); + }); + } + + /** + * @param Collection $fooManyToManyProxiedProxy + * @Serializer\VirtualProperty() + * @return $this + */ + public function setFooManyToManyProxiedProxy(Collection $fooManyToManyProxiedProxy): static + { + $this->fooManyToManyProxiedProxy = $fooManyToManyProxiedProxy; + + return $this; + } + /** + * @param Collection|array|null $fooManyToManyProxied + * @return $this + */ + public function setFooManyToManyProxied(Collection|array|null $fooManyToManyProxied = null): static + { + foreach ($this->getFooManyToManyProxiedProxy() as $item) { + $item->setNodeSource(null); + } + $this->fooManyToManyProxiedProxy->clear(); + if (null !== $fooManyToManyProxied) { + $position = 0; + foreach ($fooManyToManyProxied as $singleFooManyToManyProxied) { + $proxyEntity = new \Themes\MyTheme\Entities\PositionedCity(); + $proxyEntity->setNodeSource($this); + if ($proxyEntity instanceof \RZ\Roadiz\Core\AbstractEntities\PositionedInterface) { + $proxyEntity->setPosition(++$position); + } + $proxyEntity->setCity($singleFooManyToManyProxied); + $this->fooManyToManyProxiedProxy->add($proxyEntity); + $this->objectManager->persist($proxyEntity); + } + } + + return $this; + } + + + public function __construct(\mock\Entity\Node $node, \mock\Entity\Translation $translation) + { + parent::__construct($node, $translation); + + $this->eventReferences = new \Doctrine\Common\Collections\ArrayCollection(); + $this->eventReferencesProxiedProxy = new \Doctrine\Common\Collections\ArrayCollection(); + $this->eventReferencesExcluded = new \Doctrine\Common\Collections\ArrayCollection(); + $this->fooManyToMany = new \Doctrine\Common\Collections\ArrayCollection(); + $this->fooManyToManyProxiedProxy = new \Doctrine\Common\Collections\ArrayCollection(); + } + + #[ + Serializer\VirtualProperty, + Serializer\Groups(["nodes_sources", "nodes_sources_default"]), + Serializer\SerializedName("@type"), + SymfonySerializer\Groups(["nodes_sources", "nodes_sources_default"]), + SymfonySerializer\SerializedName(serializedName: "@type") + ] + public function getNodeTypeName(): string + { + return 'Mock'; + } + + /** + * $this->nodeType->isReachable() proxy. + * + * @return bool Does this nodeSource is reachable over network? + */ + public function isReachable(): bool + { + return true; + } + + /** + * $this->nodeType->isPublishable() proxy. + * + * @return bool Does this nodeSource is publishable with date and time? + */ + public function isPublishable(): bool + { + return true; + } + + public function __clone() + { + parent::__clone(); + + $eventReferencesProxiedProxyClone = new \Doctrine\Common\Collections\ArrayCollection(); + foreach ($this->eventReferencesProxiedProxy as $item) { + $itemClone = clone $item; + $itemClone->setNodeSource($this); + $eventReferencesProxiedProxyClone->add($itemClone); + $this->objectManager->persist($itemClone); + } + $this->eventReferencesProxiedProxy = $eventReferencesProxiedProxyClone; + + $fooManyToManyProxiedProxyClone = new \Doctrine\Common\Collections\ArrayCollection(); + foreach ($this->fooManyToManyProxiedProxy as $item) { + $itemClone = clone $item; + $itemClone->setNodeSource($this); + $fooManyToManyProxiedProxyClone->add($itemClone); + $this->objectManager->persist($itemClone); + } + $this->fooManyToManyProxiedProxy = $fooManyToManyProxiedProxyClone; + } + + public function __toString(): string + { + return '[NSMock] ' . parent::__toString(); + } +} diff --git a/lib/EntityGenerator/tests/mocks/GeneratedNodesSourcesWithRepository/NSMockRepository.php b/lib/EntityGenerator/tests/mocks/GeneratedNodesSourcesWithRepository/NSMockRepository.php new file mode 100644 index 00000000..703a9784 --- /dev/null +++ b/lib/EntityGenerator/tests/mocks/GeneratedNodesSourcesWithRepository/NSMockRepository.php @@ -0,0 +1,38 @@ + + * + * @method \tests\mocks\GeneratedNodesSourcesWithRepository\NSMock|null find($id, $lockMode = null, $lockVersion = null) + * @method \tests\mocks\GeneratedNodesSourcesWithRepository\NSMock|null findOneBy(array $criteria, array $orderBy = null) + * @method \tests\mocks\GeneratedNodesSourcesWithRepository\NSMock[] findAll() + * @method \tests\mocks\GeneratedNodesSourcesWithRepository\NSMock[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) + */ +class NSMockRepository extends \RZ\Roadiz\CoreBundle\Repository\NodesSourcesRepository +{ + public function __construct( + ManagerRegistry $registry, + PreviewResolverInterface $previewResolver, + EventDispatcherInterface $dispatcher, + Security $security, + ?NodeSourceSearchHandlerInterface $nodeSourceSearchHandler + ) { + parent::__construct($registry, $previewResolver, $dispatcher, $security, $nodeSourceSearchHandler); + + $this->_entityName = \tests\mocks\GeneratedNodesSourcesWithRepository\NSMock::class; + } +} diff --git a/lib/EntityGenerator/tests/mocks/NodeTypeAwareTrait.php b/lib/EntityGenerator/tests/mocks/NodeTypeAwareTrait.php new file mode 100644 index 00000000..4a701dff --- /dev/null +++ b/lib/EntityGenerator/tests/mocks/NodeTypeAwareTrait.php @@ -0,0 +1,223 @@ +newMockInstance(NodeTypeInterface::class); + $mockNodeType->getMockController()->getFields = function() { + return new ArrayCollection([ + (new NodeTypeField()) + ->setName('foo_datetime') + ->setTypeName('datetime') + ->setDoctrineType('datetime') + ->setSerializationGroups([ + 'nodes_sources', + 'nodes_sources_default', + 'foo_datetime' + ]) + ->setVirtual(false) + ->setLabel('Foo DateTime field') + ->setIndexed(true), + (new NodeTypeField()) + ->setName('foo') + ->setTypeName('string') + ->setVirtual(false) + ->setSerializationMaxDepth(1) + ->setLabel('Foo field') + ->setDescription('Maecenas sed diam eget risus varius blandit sit amet non magna') + ->setIndexed(false), + (new NodeTypeField()) + ->setName('fooIndexed') + ->setTypeName('string') + ->setVirtual(false) + ->setSerializationMaxDepth(1) + ->setLabel('Foo indexed field') + ->setDescription('Maecenas sed diam eget risus varius blandit sit amet non magna') + ->setIndexed(true), + (new NodeTypeField()) + ->setName('boolIndexed') + ->setTypeName('bool') + ->setDoctrineType('boolean') + ->setVirtual(false) + ->setSerializationMaxDepth(1) + ->setLabel('Bool indexed field') + ->setDescription('Maecenas sed diam eget risus varius blandit sit amet non magna') + ->setIndexed(true), + (new NodeTypeField()) + ->setName('foo_markdown') + ->setTypeName('markdown') + ->setDoctrineType('text') + ->setVirtual(false) + ->setSerializationMaxDepth(1) + ->setLabel('Foo markdown field') + ->setDescription('Maecenas sed diam eget risus varius blandit sit amet non magna') + ->setDefaultValues("allow_h2: false\r\nallow_h3: false\r\nallow_h4: false\r\nallow_h5: false\r\nallow_h6: false\r\nallow_bold: true\r\nallow_italic: true\r\nallow_blockquote: false\r\nallow_image: false\r\nallow_list: false\r\nallow_nbsp: true\r\nallow_nb_hyphen: true\r\nallow_return: true\r\nallow_link: false\r\nallow_hr: false\r\nallow_preview: true") + ->setIndexed(false), + (new NodeTypeField()) + ->setName('foo_markdown_excluded') + ->setTypeName('markdown') + ->setDoctrineType('text') + ->setVirtual(false) + ->setExcludedFromSerialization(true) + ->setLabel('Foo excluded markdown field') + ->setDescription('Maecenas sed diam eget risus varius blandit sit amet non magna') + ->setDefaultValues("allow_h2: false\r\nallow_h3: false\r\nallow_h4: false\r\nallow_h5: false\r\nallow_h6: false\r\nallow_bold: true\r\nallow_italic: true\r\nallow_blockquote: false\r\nallow_image: false\r\nallow_list: false\r\nallow_nbsp: true\r\nallow_nb_hyphen: true\r\nallow_return: true\r\nallow_link: false\r\nallow_hr: false\r\nallow_preview: true") + ->setIndexed(false), + (new NodeTypeField()) + ->setName('foo_decimal_excluded') + ->setTypeName('decimal') + ->setDoctrineType('decimal') + ->setVirtual(false) + ->setSerializationExclusionExpression('object.foo == \'test\'') + ->setLabel('Foo expression excluded decimal') + ->setDescription('Maecenas sed diam eget risus varius blandit sit amet non magna') + ->setIndexed(true), + (new NodeTypeField()) + ->setName('single_event_reference') + ->setTypeName('many_to_one') + ->setVirtual(false) + ->setLabel("Référence à l'événement") + ->setDescription('Maecenas sed diam eget risus varius blandit sit amet non magna') + ->setDefaultValues("# Entity class name\r\nclassname: \\App\\Entity\\Base\\Event\r\n# Displayable is the method used to display entity name\r\ndisplayable: getName\r\n# Same as Displayable but for a secondary information\r\nalt_displayable: getSortingFirstDateTime\r\n# Same as Displayable but for a secondary information\r\nthumbnail: getMainDocument\r\n# Searchable entity fields\r\nsearchable:\r\n - name\r\n - slug\r\n# This order will only be used for explorer\r\norderBy:\r\n - field: sortingLastDateTime\r\n direction: DESC") + ->setIndexed(false), + (new NodeTypeField()) + ->setName('event_references') + ->setTypeName('many_to_many') + ->setVirtual(false) + ->setLabel("Remontée d'événements manuelle") + ->setDescription('Maecenas sed diam eget risus varius blandit sit amet non magna') + ->setDefaultValues("# Entity class name\r\nclassname: \\App\\Entity\\Base\\Event\r\n# Displayable is the method used to display entity name\r\ndisplayable: getName\r\n# Same as Displayable but for a secondary information\r\nalt_displayable: getSortingFirstDateTime\r\n# Same as Displayable but for a secondary information\r\nthumbnail: getMainDocument\r\n# Searchable entity fields\r\nsearchable:\r\n - name\r\n - slug\r\n# This order will only be used for explorer\r\norderBy:\r\n - field: sortingLastDateTime\r\n direction: DESC") + ->setIndexed(false), + (new NodeTypeField()) + ->setName('event_references_proxied') + ->setTypeName('many_to_many') + ->setVirtual(false) + ->setLabel("Remontée d'événements manuelle") + ->setDescription('Maecenas sed diam eget risus varius blandit sit amet non magna') + ->setDefaultValues("# Entity class name\r\nclassname: \\App\\Entity\\Base\\Event\r\n# Displayable is the method used to display entity name\r\ndisplayable: getName\r\n# Same as Displayable but for a secondary information\r\nalt_displayable: getSortingFirstDateTime\r\n# Same as Displayable but for a secondary information\r\nthumbnail: getMainDocument\r\n# Searchable entity fields\r\nsearchable:\r\n - name\r\n - slug\r\n# This order will only be used for explorer\r\norderBy:\r\n - field: sortingLastDateTime\r\n direction: DESC\r\n# Use a proxy entity\r\nproxy:\r\n classname: \\App\\Entity\\PositionedCity\r\n self: nodeSource\r\n relation: city\r\n # This order will preserve position\r\n orderBy:\r\n - field: position\r\n direction: ASC") + ->setIndexed(false), + (new NodeTypeField()) + ->setName('event_references_excluded') + ->setTypeName('many_to_many') + ->setVirtual(false) + ->setExcludedFromSerialization(true) + ->setLabel("Remontée d'événements manuelle exclue") + ->setDescription('Maecenas sed diam eget risus varius blandit sit amet non magna') + ->setDefaultValues("# Entity class name\r\nclassname: \\App\\Entity\\Base\\Event\r\n# Displayable is the method used to display entity name\r\ndisplayable: getName\r\n# Same as Displayable but for a secondary information\r\nalt_displayable: getSortingFirstDateTime\r\n# Same as Displayable but for a secondary information\r\nthumbnail: getMainDocument\r\n# Searchable entity fields\r\nsearchable:\r\n - name\r\n - slug\r\n# This order will only be used for explorer\r\norderBy:\r\n - field: sortingLastDateTime\r\n direction: DESC") + ->setIndexed(false), + (new NodeTypeField()) + ->setName('bar') + ->setTypeName('documents') + ->setSerializationMaxDepth(1) + ->setVirtual(true) + ->setLabel('Bar documents field') + ->setDescription('Maecenas sed diam eget risus varius blandit sit amet non magna') + ->setIndexed(false), + (new NodeTypeField()) + ->setName('the_forms') + ->setTypeName('custom_forms') + ->setVirtual(true) + ->setLabel('Custom forms field') + ->setIndexed(false), + (new NodeTypeField()) + ->setName('foo_bar') + ->setTypeName('nodes') + ->setVirtual(true) + ->setLabel('ForBar nodes field') + ->setDescription('Maecenas sed diam eget risus varius blandit sit amet non magna') + ->setIndexed(false), + (new NodeTypeField()) + ->setName('foo_bar_typed') + ->setTypeName('nodes') + ->setVirtual(true) + ->setLabel('ForBar nodes typed field') + ->setIndexed(false) + ->setDefaultValues('MockTwo'), + (new NodeTypeField()) + ->setName('foo_many_to_one') + ->setTypeName('many_to_one') + ->setVirtual(false) + ->setLabel('For many_to_one field') + ->setDefaultValues(<<setIndexed(false), + (new NodeTypeField()) + ->setName('foo_many_to_many') + ->setTypeName('many_to_many') + ->setVirtual(false) + ->setLabel('For many_to_many field') + ->setDefaultValues(<<setIndexed(false), + (new NodeTypeField()) + ->setName('foo_many_to_many_proxied') + ->setTypeName('many_to_many') + ->setVirtual(false) + ->setSerializationMaxDepth(1) + ->setLabel('For many_to_many proxied field') + ->setDefaultValues(<<setIndexed(false), + ]); + }; + $mockNodeType->getMockController()->getSourceEntityTableName = function() { + return 'ns_mock'; + }; + $mockNodeType->getMockController()->getSourceEntityClassName = function() { + return 'NSMock'; + }; + $mockNodeType->getMockController()->getName = function() { + return 'Mock'; + }; + $mockNodeType->getMockController()->isReachable = function() { + return true; + }; + $mockNodeType->getMockController()->isPublishable = function() { + return true; + }; + return $mockNodeType; + } + + protected function getMockNodeTypeResolver() + { + $mockNodeTypeResolver = $this->newMockInstance(NodeTypeResolverInterface::class); + $test = $this; + $mockNodeTypeResolver->getMockController()->get = function(string $nodeTypeName) use ($test) { + $mockNodeType = $test->newMockInstance(NodeTypeInterface::class); + $mockNodeType->getMockController()->getSourceEntityFullQualifiedClassName = function() use ($nodeTypeName) { + return 'tests\mocks\GeneratedNodesSources\NS' . $nodeTypeName; + }; + return $mockNodeType; + }; + return $mockNodeTypeResolver; + } +} diff --git a/lib/EntityGenerator/tests/mocks/NodeTypeField.php b/lib/EntityGenerator/tests/mocks/NodeTypeField.php new file mode 100644 index 00000000..22e9dca4 --- /dev/null +++ b/lib/EntityGenerator/tests/mocks/NodeTypeField.php @@ -0,0 +1,586 @@ +description; + } + + /** + * @param string|null $description + * @return NodeTypeField + */ + public function setDescription(?string $description): NodeTypeField + { + $this->description = $description; + return $this; + } + + /** + * @return string + */ + public function getLabel(): string + { + return $this->label ?? ''; + } + + /** + * @param string|null $label + * @return NodeTypeField + */ + public function setLabel(?string $label): NodeTypeField + { + $this->label = $label; + return $this; + } + + /** + * @return string + */ + public function getName(): string + { + return $this->name ?? ''; + } + + /** + * @param string|null $name + * @return NodeTypeField + */ + public function setName(?string $name): NodeTypeField + { + $this->name = $name; + return $this; + } + + /** + * @return string|null + */ + public function getPlaceholder(): ?string + { + return $this->placeholder; + } + + /** + * @param string|null $placeholder + * @return NodeTypeField + */ + public function setPlaceholder(?string $placeholder): NodeTypeField + { + $this->placeholder = $placeholder; + return $this; + } + + /** + * @return string|null + */ + public function getDefaultValues(): ?string + { + return $this->defaultValues; + } + + /** + * @param string|null $defaultValues + * @return NodeTypeField + */ + public function setDefaultValues(?string $defaultValues): NodeTypeField + { + $this->defaultValues = $defaultValues; + return $this; + } + + /** + * @return string|null + */ + public function getGroupName(): ?string + { + return $this->groupName; + } + + /** + * @param string|null $groupName + * @return NodeTypeField + */ + public function setGroupName(?string $groupName): NodeTypeField + { + $this->groupName = $groupName; + return $this; + } + + /** + * @return int|null + */ + public function getMinLength(): ?int + { + return $this->minLength; + } + + /** + * @param int|null $minLength + * @return NodeTypeField + */ + public function setMinLength(?int $minLength): NodeTypeField + { + $this->minLength = $minLength; + return $this; + } + + /** + * @return int|null + */ + public function getMaxLength(): ?int + { + return $this->maxLength; + } + + /** + * @param int|null $maxLength + * @return NodeTypeField + */ + public function setMaxLength(?int $maxLength): NodeTypeField + { + $this->maxLength = $maxLength; + return $this; + } + + /** + * @return bool + */ + public function isVisible(): bool + { + return $this->visible; + } + + /** + * @param bool $visible + * @return NodeTypeField + */ + public function setVisible(bool $visible): NodeTypeField + { + $this->visible = $visible; + return $this; + } + + /** + * @return bool + */ + public function isUniversal(): bool + { + return $this->universal; + } + + /** + * @param bool $universal + * @return NodeTypeField + */ + public function setUniversal(bool $universal): NodeTypeField + { + $this->universal = $universal; + return $this; + } + + /** + * @return bool + */ + public function isSearchable(): bool + { + return $this->searchable; + } + + /** + * @param bool $searchable + * @return NodeTypeField + */ + public function setSearchable(bool $searchable): NodeTypeField + { + $this->searchable = $searchable; + return $this; + } + + /** + * @return bool + */ + public function isVirtual(): bool + { + return $this->virtual; + } + + /** + * @param bool $virtual + * @return NodeTypeField + */ + public function setVirtual(bool $virtual): NodeTypeField + { + $this->virtual = $virtual; + return $this; + } + + /** + * @return bool + */ + public function isIndexed(): bool + { + return $this->indexed; + } + + /** + * @param bool $indexed + * @return NodeTypeField + */ + public function setIndexed(bool $indexed): NodeTypeField + { + $this->indexed = $indexed; + return $this; + } + + /** + * @return bool + */ + public function isExpanded(): bool + { + return $this->expanded; + } + + /** + * @param bool $expanded + * @return NodeTypeField + */ + public function setExpanded(bool $expanded): NodeTypeField + { + $this->expanded = $expanded; + return $this; + } + + /** + * @return string + */ + public function getTypeName(): string + { + return $this->typeName; + } + + /** + * @param string $typeName + * @return NodeTypeField + */ + public function setTypeName(string $typeName): NodeTypeField + { + $this->typeName = $typeName; + return $this; + } + + /** + * @return string + */ + public function getNodeTypeName(): string + { + return $this->nodeTypeName; + } + + /** + * @param string $nodeTypeName + * @return NodeTypeField + */ + public function setNodeTypeName(string $nodeTypeName): NodeTypeField + { + $this->nodeTypeName = $nodeTypeName; + return $this; + } + + /** + * @return string + */ + public function getDoctrineType(): string + { + return $this->doctrineType; + } + + /** + * @param string $doctrineType + * @return NodeTypeField + */ + public function setDoctrineType(string $doctrineType): NodeTypeField + { + $this->doctrineType = $doctrineType; + return $this; + } + + public function getVarName(): string + { + return (new UnicodeString($this->getName()))->camel()->toString(); + } + + public function getGetterName(): string + { + return (new UnicodeString('get ' . $this->getName()))->camel()->toString(); + } + + public function getSetterName(): string + { + return (new UnicodeString('set ' . $this->getName()))->camel()->toString(); + } + + public function getGroupNameCanonical(): ?string + { + return $this->groupName; + } + + public function getType() + { + return $this->typeName; + } + + public function isString(): bool + { + return $this->typeName === 'string'; + } + + public function isText(): bool + { + return $this->typeName === 'text'; + } + + public function isDate(): bool + { + return $this->typeName === 'date'; + } + + public function isDateTime(): bool + { + return $this->typeName === 'datetime'; + } + + public function isRichText(): bool + { + return $this->typeName === 'richtext'; + } + + public function isMarkdown(): bool + { + return $this->typeName === 'markdown'; + } + + public function isBool(): bool + { + return $this->typeName === 'bool'; + } + + public function isInteger(): bool + { + return $this->typeName === 'integer'; + } + + public function isDecimal(): bool + { + return $this->typeName === 'decimal'; + } + + public function isEmail(): bool + { + return $this->typeName === 'email'; + } + + public function isDocuments(): bool + { + return $this->typeName === 'documents'; + } + + public function isPassword(): bool + { + return $this->typeName === 'password'; + } + + public function isColor(): bool + { + return $this->typeName === 'color'; + } + + public function isGeoTag(): bool + { + return $this->typeName === 'geotag'; + } + + public function isNodes(): bool + { + return $this->typeName === 'nodes'; + } + + public function isUser(): bool + { + return $this->typeName === 'user'; + } + + public function isEnum(): bool + { + return $this->typeName === 'enum'; + } + + public function isChildrenNodes(): bool + { + return $this->typeName === 'children'; + } + + public function isCustomForms(): bool + { + return $this->typeName === 'custom_forms'; + } + + public function isMultiple(): bool + { + return $this->typeName === 'multiple'; + } + + public function isMultiGeoTag(): bool + { + return $this->typeName === 'multi_geotag'; + } + + public function isJson(): bool + { + return $this->typeName === 'json'; + } + + public function isYaml(): bool + { + return $this->typeName === 'yaml'; + } + + public function isCss(): bool + { + return $this->typeName === 'css'; + } + + public function isManyToMany(): bool + { + return $this->typeName === 'many_to_many'; + } + + public function isManyToOne(): bool + { + return $this->typeName === 'many_to_one'; + } + + public function isCountry(): bool + { + return $this->typeName === 'country'; + } + + public function isSingleProvider(): bool + { + return $this->typeName === 'single_provider'; + } + + public function isMultiProvider(): bool + { + return $this->typeName === 'multi_provider'; + } + + public function isCollection(): bool + { + return $this->typeName === 'collection'; + } + + /** + * @return array + */ + public function getSerializationGroups(): array + { + return $this->serializationGroups; + } + + /** + * @param array $serializationGroups + * @return NodeTypeField + */ + public function setSerializationGroups(array $serializationGroups): NodeTypeField + { + $this->serializationGroups = $serializationGroups; + return $this; + } + + /** + * @return bool + */ + public function isExcludedFromSerialization(): bool + { + return $this->excludedFromSerialization; + } + + /** + * @param bool $excludedFromSerialization + * @return NodeTypeField + */ + public function setExcludedFromSerialization(bool $excludedFromSerialization): NodeTypeField + { + $this->excludedFromSerialization = $excludedFromSerialization; + return $this; + } + + /** + * @return string|null + */ + public function getSerializationExclusionExpression(): ?string + { + return $this->serializationExclusionExpression; + } + + /** + * @param string|null $serializationExclusionExpression + * @return NodeTypeField + */ + public function setSerializationExclusionExpression(?string $serializationExclusionExpression): NodeTypeField + { + $this->serializationExclusionExpression = $serializationExclusionExpression; + return $this; + } + + /** + * @return int|null + */ + public function getSerializationMaxDepth(): ?int + { + return $this->serializationMaxDepth; + } + + /** + * @param int|null $serializationMaxDepth + * @return NodeTypeField + */ + public function setSerializationMaxDepth(?int $serializationMaxDepth): NodeTypeField + { + $this->serializationMaxDepth = $serializationMaxDepth; + return $this; + } +} diff --git a/lib/EntityGenerator/tests/units/EntityGenerator.php b/lib/EntityGenerator/tests/units/EntityGenerator.php new file mode 100644 index 00000000..6a7f836d --- /dev/null +++ b/lib/EntityGenerator/tests/units/EntityGenerator.php @@ -0,0 +1,82 @@ +getMockNodeType(); + $mockNodeTypeResolver = $this->getMockNodeTypeResolver(); + + /* + * Uncomment for generating a mock file from tests + */ +// $dumpInstance = $this->newTestedInstance($mockNodeType, $mockNodeTypeResolver, [ +// 'parent_class' => '\mock\Entity\NodesSources', +// 'node_class' => '\mock\Entity\Node', +// 'translation_class' => '\mock\Entity\Translation', +// 'document_class' => '\mock\Entity\Document', +// 'document_proxy_class' => '\mock\Entity\NodesSourcesDocument', +// 'custom_form_class' => '\mock\Entity\CustomForm', +// 'custom_form_proxy_class' => '\mock\Entity\NodesSourcesCustomForm', +// 'repository_class' => '\mock\Entity\Repository\NodesSourcesRepository', +// 'namespace' => '\tests\mocks\GeneratedNodesSources', +// 'use_native_json' => true, +// 'use_api_platform_filters' => true, +// ]); +// file_put_contents( +// dirname(__DIR__) . '/mocks/GeneratedNodesSources/NSMock.php', +// $dumpInstance->getClassContent() +// ); + + $this + // creation of a new instance of the tested class + ->given($this->newTestedInstance($mockNodeType, $mockNodeTypeResolver, [ + 'parent_class' => '\mock\Entity\NodesSources', + 'node_class' => '\mock\Entity\Node', + 'translation_class' => '\mock\Entity\Translation', + 'document_class' => '\mock\Entity\Document', + 'document_proxy_class' => '\mock\Entity\NodesSourcesDocument', + 'custom_form_class' => '\mock\Entity\CustomForm', + 'custom_form_proxy_class' => '\mock\Entity\NodesSourcesCustomForm', + 'repository_class' => '\mock\Entity\Repository\NodesSourcesRepository', + 'namespace' => '\tests\mocks\GeneratedNodesSources', + 'use_native_json' => true, + 'use_api_platform_filters' => true, + ])) + ->then + ->string($this->testedInstance->getClassContent()) + ->isEqualTo(file_get_contents(dirname(__DIR__) . '/mocks/GeneratedNodesSources/NSMock.php')) + ; + + /** + * TEST without leading slashs + */ + $this + // creation of a new instance of the tested class + ->given($this->newTestedInstance($mockNodeType, $mockNodeTypeResolver, [ + 'parent_class' => 'mock\Entity\NodesSources', + 'node_class' => 'mock\Entity\Node', + 'translation_class' => 'mock\Entity\Translation', + 'document_class' => 'mock\Entity\Document', + 'document_proxy_class' => 'mock\Entity\NodesSourcesDocument', + 'custom_form_class' => 'mock\Entity\CustomForm', + 'custom_form_proxy_class' => 'mock\Entity\NodesSourcesCustomForm', + 'repository_class' => 'mock\Entity\Repository\NodesSourcesRepository', + 'namespace' => 'tests\mocks\GeneratedNodesSources', + 'use_native_json' => true, + 'use_api_platform_filters' => true, + ])) + ->then + ->string($this->testedInstance->getClassContent()) + ->isEqualTo(file_get_contents(dirname(__DIR__) . '/mocks/GeneratedNodesSources/NSMock.php')) + ; + } +} diff --git a/lib/EntityGenerator/tests/units/EntityGeneratorFactory.php b/lib/EntityGenerator/tests/units/EntityGeneratorFactory.php new file mode 100644 index 00000000..8e154418 --- /dev/null +++ b/lib/EntityGenerator/tests/units/EntityGeneratorFactory.php @@ -0,0 +1,132 @@ +getMockNodeType(); + $mockNodeTypeResolver = $this->getMockNodeTypeResolver(); + + $this + // creation of a new instance of the tested class + ->given($this->newTestedInstance($mockNodeTypeResolver, [ + 'parent_class' => '\mock\Entity\NodesSources', + 'node_class' => '\mock\Entity\Node', + 'translation_class' => '\mock\Entity\Translation', + 'document_class' => '\mock\Entity\Document', + 'document_proxy_class' => '\mock\Entity\NodesSourcesDocument', + 'custom_form_class' => '\mock\Entity\CustomForm', + 'custom_form_proxy_class' => '\mock\Entity\NodesSourcesCustomForm', + 'repository_class' => '\mock\Entity\Repository\NodesSourcesRepository', + 'namespace' => '\tests\mocks\GeneratedNodesSources', + 'use_native_json' => true, + 'use_api_platform_filters' => true, + ])) + ->then + ->string($this->testedInstance->create($mockNodeType)->getClassContent()) + ->isEqualTo(file_get_contents(dirname(__DIR__) . '/mocks/GeneratedNodesSources/NSMock.php')) + ; + } + + public function testCreateWithCustomRepository() + { + $mockNodeType = $this->getMockNodeType(); + $mockNodeTypeResolver = $this->getMockNodeTypeResolver(); + + /* + * Uncomment for generating a mock file from tests + */ +// $dumpInstance = $this->newTestedInstance($mockNodeTypeResolver, [ +// 'parent_class' => '\mock\Entity\NodesSources', +// 'node_class' => '\mock\Entity\Node', +// 'translation_class' => '\mock\Entity\Translation', +// 'document_class' => '\mock\Entity\Document', +// 'document_proxy_class' => '\mock\Entity\NodesSourcesDocument', +// 'custom_form_class' => '\mock\Entity\CustomForm', +// 'custom_form_proxy_class' => '\mock\Entity\NodesSourcesCustomForm', +// 'repository_class' => '\mock\Entity\Repository\NodesSourcesRepository', +// 'namespace' => '\tests\mocks\GeneratedNodesSourcesWithRepository', +// 'use_native_json' => true, +// 'use_api_platform_filters' => true, +// ]); +// file_put_contents( +// dirname(__DIR__) . '/mocks/GeneratedNodesSourcesWithRepository/NSMock.php', +// $dumpInstance->createWithCustomRepository($mockNodeType)->getClassContent() +// ); + + $this + // creation of a new instance of the tested class + ->given($this->newTestedInstance($mockNodeTypeResolver, [ + 'parent_class' => '\mock\Entity\NodesSources', + 'node_class' => '\mock\Entity\Node', + 'translation_class' => '\mock\Entity\Translation', + 'document_class' => '\mock\Entity\Document', + 'document_proxy_class' => '\mock\Entity\NodesSourcesDocument', + 'custom_form_class' => '\mock\Entity\CustomForm', + 'custom_form_proxy_class' => '\mock\Entity\NodesSourcesCustomForm', + 'repository_class' => '\mock\Entity\Repository\NodesSourcesRepository', + 'namespace' => '\tests\mocks\GeneratedNodesSourcesWithRepository', + 'use_native_json' => true, + 'use_api_platform_filters' => true, + ])) + ->then + ->string($this->testedInstance->createWithCustomRepository($mockNodeType)->getClassContent()) + ->isEqualTo(file_get_contents(dirname(__DIR__) . '/mocks/GeneratedNodesSourcesWithRepository/NSMock.php')) + ; + } + + public function testCreateCustomRepository() + { + $mockNodeType = $this->getMockNodeType(); + $mockNodeTypeResolver = $this->getMockNodeTypeResolver(); + + /* + * Uncomment for generating a mock file from tests + */ +// $dumpInstance = $this->newTestedInstance($mockNodeTypeResolver, [ +// 'parent_class' => '\mock\Entity\NodesSources', +// 'node_class' => '\mock\Entity\Node', +// 'translation_class' => '\mock\Entity\Translation', +// 'document_class' => '\mock\Entity\Document', +// 'document_proxy_class' => '\mock\Entity\NodesSourcesDocument', +// 'custom_form_class' => '\mock\Entity\CustomForm', +// 'custom_form_proxy_class' => '\mock\Entity\NodesSourcesCustomForm', +// 'repository_class' => '\mock\Entity\Repository\NodesSourcesRepository', +// 'namespace' => '\tests\mocks\GeneratedNodesSourcesWithRepository', +// 'use_native_json' => true, +// 'use_api_platform_filters' => true, +// ]); +// file_put_contents( +// dirname(__DIR__) . '/mocks/GeneratedNodesSourcesWithRepository/NSMockRepository.php', +// $dumpInstance->createCustomRepository($mockNodeType)->getClassContent() +// ); + + $this + // creation of a new instance of the tested class + ->given($this->newTestedInstance($mockNodeTypeResolver, [ + 'parent_class' => '\mock\Entity\NodesSources', + 'node_class' => '\mock\Entity\Node', + 'translation_class' => '\mock\Entity\Translation', + 'document_class' => '\mock\Entity\Document', + 'document_proxy_class' => '\mock\Entity\NodesSourcesDocument', + 'custom_form_class' => '\mock\Entity\CustomForm', + 'custom_form_proxy_class' => '\mock\Entity\NodesSourcesCustomForm', + 'repository_class' => '\mock\Entity\Repository\NodesSourcesRepository', + 'namespace' => '\tests\mocks\GeneratedNodesSourcesWithRepository', + 'use_native_json' => true, + 'use_api_platform_filters' => true, + ])) + ->then + ->string($this->testedInstance->createCustomRepository($mockNodeType)->getClassContent()) + ->isEqualTo(file_get_contents(dirname(__DIR__) . '/mocks/GeneratedNodesSourcesWithRepository/NSMockRepository.php')) + ; + } +} diff --git a/lib/Jwt b/lib/Jwt deleted file mode 160000 index 0b23d194..00000000 --- a/lib/Jwt +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 0b23d1940f62f3cd3eee2e0646cd0b1f382d12e2 diff --git a/lib/Jwt/.github/workflows/run-test.yml b/lib/Jwt/.github/workflows/run-test.yml new file mode 100644 index 00000000..0b3245e5 --- /dev/null +++ b/lib/Jwt/.github/workflows/run-test.yml @@ -0,0 +1,41 @@ +name: Unit tests, static analysis and code style + +on: + push: + branches: + - develop + - 'release/**' + - 'hotfix/**' + tags: ['**'] + pull_request: + types: + - opened + - reopened + - synchronize + - ready_for_review + +jobs: + run-tests: + runs-on: ubuntu-latest + strategy: + matrix: + php-version: ['8.0', '8.1'] + steps: + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + - uses: actions/checkout@v3 + - name: Cache Composer packages + id: composer-cache + uses: actions/cache@v3 + with: + path: vendor + key: ${{ runner.os }}-php-${{ matrix.php-version }}-${{ hashFiles('**/composer.lock') }} + restore-keys: | + ${{ runner.os }}-php-${{ matrix.php-version }}- + - name: Install Dependencies + run: composer install --no-scripts --no-ansi --no-interaction --no-progress + - name: Run PHP Code Sniffer + run: vendor/bin/phpcs --extensions=php --warning-severity=0 --standard=PSR12 -p ./src + - name: Run PHPStan + run: vendor/bin/phpstan analyse --no-progress -c phpstan.neon diff --git a/lib/Jwt/.gitignore b/lib/Jwt/.gitignore new file mode 100644 index 00000000..1881d82c --- /dev/null +++ b/lib/Jwt/.gitignore @@ -0,0 +1,175 @@ +### Composer ### +composer.phar +/vendor/ + +# Commit your application's lock file https://getcomposer.org/doc/01-basic-usage.md#commit-your-composer-lock-file-to-version-control +# You may choose to ignore a library lock file http://getcomposer.org/doc/02-libraries.md#lock-file +composer.lock + +### PHPCodeSniffer ### +# CodeSniffer + +/vendor/* +/wpcs/* + +### PhpStorm ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +### PhpStorm Patch ### +# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 + +# *.iml +# modules.xml +# .idea/misc.xml +# *.ipr + +# Sonarlint plugin +# https://plugins.jetbrains.com/plugin/7973-sonarlint +.idea/**/sonarlint/ + +# SonarQube Plugin +# https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin +.idea/**/sonarIssues.xml + +# Markdown Navigator plugin +# https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced +.idea/**/markdown-navigator.xml +.idea/**/markdown-navigator-enh.xml +.idea/**/markdown-navigator/ + +# Cache file creation bug +# See https://youtrack.jetbrains.com/issue/JBR-2257 +.idea/$CACHE_FILE$ + +# CodeStream plugin +# https://plugins.jetbrains.com/plugin/12206-codestream +.idea/codestream.xml + +### Symfony ### +# Cache and logs (Symfony2) +/app/cache/* +/app/logs/* +!app/cache/.gitkeep +!app/logs/.gitkeep + +# Email spool folder +/app/spool/* + +# Cache, session files and logs (Symfony3) +/var/cache/* +/var/logs/* +/var/sessions/* +!var/cache/.gitkeep +!var/logs/.gitkeep +!var/sessions/.gitkeep + +# Logs (Symfony4) +/var/log/* +!var/log/.gitkeep + +# Parameters +/app/config/parameters.yml +/app/config/parameters.ini + +# Managed by Composer +/app/bootstrap.php.cache +/var/bootstrap.php.cache +/bin/* +!bin/console +!bin/symfony_requirements + +# Assets and user uploads +/web/bundles/ +/web/uploads/ + +# PHPUnit +/app/phpunit.xml +/phpunit.xml + +# Build data +/build/ + +# Composer PHAR +/composer.phar + +# Backup entities generated with doctrine:generate:entities command +**/Entity/*~ + +# Embedded web-server pid file +/.web-server-pid + +### Symfony Patch ### +/web/css/ +/web/js/ +/.phpcs-cache +/report.txt \ No newline at end of file diff --git a/lib/Jwt/.travis.yml b/lib/Jwt/.travis.yml new file mode 100644 index 00000000..768dab57 --- /dev/null +++ b/lib/Jwt/.travis.yml @@ -0,0 +1,15 @@ +language: php +sudo: required +php: + - 8.0 + - 8.1 + - nightly +install: + - curl -s http://getcomposer.org/installer | php + - php composer.phar install --dev --no-interaction +script: + - vendor/bin/phpcs --report=full --report-file=./report.txt -p ./src + - vendor/bin/phpstan analyse -c phpstan.neon +jobs: + allow_failures: + - php: nightly diff --git a/lib/Jwt/LICENSE b/lib/Jwt/LICENSE new file mode 100644 index 00000000..9549c142 --- /dev/null +++ b/lib/Jwt/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Roadiz + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/lib/Jwt/Makefile b/lib/Jwt/Makefile new file mode 100644 index 00000000..7e60d0ef --- /dev/null +++ b/lib/Jwt/Makefile @@ -0,0 +1,4 @@ + +test: + vendor/bin/phpcs --report=full --report-file=./report.txt -p ./src + vendor/bin/phpstan analyse -c phpstan.neon diff --git a/lib/Jwt/README.md b/lib/Jwt/README.md new file mode 100644 index 00000000..a534c5bd --- /dev/null +++ b/lib/Jwt/README.md @@ -0,0 +1,4 @@ +# jwt +Roadiz sub-package for handling JWT + +[![Unit tests, static analysis and code style](https://github.com/roadiz/jwt/actions/workflows/run-test.yml/badge.svg?branch=develop)](https://github.com/roadiz/jwt/actions/workflows/run-test.yml) diff --git a/lib/Jwt/composer.json b/lib/Jwt/composer.json new file mode 100644 index 00000000..bb0125ec --- /dev/null +++ b/lib/Jwt/composer.json @@ -0,0 +1,34 @@ +{ + "name": "roadiz/jwt", + "description": "Roadiz sub-package for handling JWT", + "type": "library", + "license": "MIT", + "authors": [ + { + "name": "Ambroise Maupate", + "email": "ambroise@roadiz.io", + "homepage": "https://www.roadiz.io", + "role": "Lead developer" + } + ], + "require": { + "php": ">=8.0", + "lcobucci/jwt": "^4.1", + "guzzlehttp/guzzle": "^7.2.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.5.3", + "squizlabs/php_codesniffer": "^3.5" + }, + "autoload": { + "psr-4": { + "RZ\\Roadiz\\JWT\\": "src/" + } + }, + "extra": { + "branch-alias": { + "dev-main": "2.0.x-dev", + "dev-develop": "2.1.x-dev" + } + } +} diff --git a/lib/Jwt/phpcs.xml.dist b/lib/Jwt/phpcs.xml.dist new file mode 100644 index 00000000..da4bfdb5 --- /dev/null +++ b/lib/Jwt/phpcs.xml.dist @@ -0,0 +1,14 @@ + + + + + + + + + + + + ./src + diff --git a/lib/Jwt/phpstan.neon b/lib/Jwt/phpstan.neon new file mode 100644 index 00000000..2359c3d9 --- /dev/null +++ b/lib/Jwt/phpstan.neon @@ -0,0 +1,7 @@ +parameters: + level: max + paths: + - src + reportUnmatchedIgnoredErrors: false + checkGenericClassInNonGenericObjectType: false + checkMissingIterableValueType: false diff --git a/lib/Jwt/src/JwtConfigurationFactory.php b/lib/Jwt/src/JwtConfigurationFactory.php new file mode 100644 index 00000000..ab8d69d0 --- /dev/null +++ b/lib/Jwt/src/JwtConfigurationFactory.php @@ -0,0 +1,12 @@ +hostedDomain = $hostedDomain; + } + + public function assert(Token $token): void + { + if ($token instanceof Token\Plain && !empty($this->hostedDomain)) { + if (!$token->claims()->has('hd')) { + throw new ConstraintViolation( + 'Token does not expose any Hosted Domain.' + ); + } + /* + * Check that Hosted Domain is the same as required by Roadiz + */ + if ($token->claims()->get('hd') !== $this->hostedDomain) { + throw new ConstraintViolation( + 'User (' . $token->claims()->get('hd') . ') does not belong to Hosted Domain.' + ); + } + } + } +} diff --git a/lib/Jwt/src/Validation/Constraint/UserInfoEndpoint.php b/lib/Jwt/src/Validation/Constraint/UserInfoEndpoint.php new file mode 100644 index 00000000..8dac58a7 --- /dev/null +++ b/lib/Jwt/src/Validation/Constraint/UserInfoEndpoint.php @@ -0,0 +1,42 @@ +userInfoEndpoint = $userInfoEndpoint; + } + + public function assert(Token $token): void + { + try { + $client = new Client(); + $client->get($this->userInfoEndpoint, [ + 'headers' => [ + 'Authorization' => 'Bearer ' . $token->toString(), + ], + ]); + } catch (GuzzleException $e) { + throw new ConstraintViolation( + 'Userinfo cannot be fetch from Identity provider', + $e->getCode(), + $e + ); + } + } +} diff --git a/lib/Markdown b/lib/Markdown deleted file mode 160000 index dd2d2872..00000000 --- a/lib/Markdown +++ /dev/null @@ -1 +0,0 @@ -Subproject commit dd2d2872f2a1f6561456f5f1ef64f482098c57f6 diff --git a/lib/Markdown/.editorconfig b/lib/Markdown/.editorconfig new file mode 100644 index 00000000..c3ccb818 --- /dev/null +++ b/lib/Markdown/.editorconfig @@ -0,0 +1,17 @@ +# Roadiz editor config for contributors +# http://editorconfig.org/ +root = true + +[*] +indent_style = space +indent_size = 4 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.md] +trim_trailing_whitespace = false + +[Makefile] +indent_style = tab diff --git a/lib/Markdown/.github/workflows/run-test.yml b/lib/Markdown/.github/workflows/run-test.yml new file mode 100644 index 00000000..0b3245e5 --- /dev/null +++ b/lib/Markdown/.github/workflows/run-test.yml @@ -0,0 +1,41 @@ +name: Unit tests, static analysis and code style + +on: + push: + branches: + - develop + - 'release/**' + - 'hotfix/**' + tags: ['**'] + pull_request: + types: + - opened + - reopened + - synchronize + - ready_for_review + +jobs: + run-tests: + runs-on: ubuntu-latest + strategy: + matrix: + php-version: ['8.0', '8.1'] + steps: + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + - uses: actions/checkout@v3 + - name: Cache Composer packages + id: composer-cache + uses: actions/cache@v3 + with: + path: vendor + key: ${{ runner.os }}-php-${{ matrix.php-version }}-${{ hashFiles('**/composer.lock') }} + restore-keys: | + ${{ runner.os }}-php-${{ matrix.php-version }}- + - name: Install Dependencies + run: composer install --no-scripts --no-ansi --no-interaction --no-progress + - name: Run PHP Code Sniffer + run: vendor/bin/phpcs --extensions=php --warning-severity=0 --standard=PSR12 -p ./src + - name: Run PHPStan + run: vendor/bin/phpstan analyse --no-progress -c phpstan.neon diff --git a/lib/Markdown/.gitignore b/lib/Markdown/.gitignore new file mode 100644 index 00000000..8ae33650 --- /dev/null +++ b/lib/Markdown/.gitignore @@ -0,0 +1,14 @@ + +# Created by https://www.gitignore.io/api/composer +# Edit at https://www.gitignore.io/?templates=composer + +### Composer ### +composer.phar +/vendor/ + +# Commit your application's lock file https://getcomposer.org/doc/01-basic-usage.md#commit-your-composer-lock-file-to-version-control +# You may choose to ignore a library lock file http://getcomposer.org/doc/02-libraries.md#lock-file +composer.lock + +# End of https://www.gitignore.io/api/composer +report.txt diff --git a/lib/Markdown/.travis.yml b/lib/Markdown/.travis.yml new file mode 100644 index 00000000..768dab57 --- /dev/null +++ b/lib/Markdown/.travis.yml @@ -0,0 +1,15 @@ +language: php +sudo: required +php: + - 8.0 + - 8.1 + - nightly +install: + - curl -s http://getcomposer.org/installer | php + - php composer.phar install --dev --no-interaction +script: + - vendor/bin/phpcs --report=full --report-file=./report.txt -p ./src + - vendor/bin/phpstan analyse -c phpstan.neon +jobs: + allow_failures: + - php: nightly diff --git a/lib/Markdown/CHANGELOG.md b/lib/Markdown/CHANGELOG.md new file mode 100644 index 00000000..56dd1c72 --- /dev/null +++ b/lib/Markdown/CHANGELOG.md @@ -0,0 +1,15 @@ +## 2.0.0 (2022-06-28) + +### ⚠ BREAKING CHANGES + +* Removed Parsedown lib support +* Removed Pimple + +### Features + +* Removed dead code and Pimple dependency ([4c45885](https://github.com/roadiz/markdown/commit/4c458852f7ebdf03a199c799ae438176385bdc02)) +* Removed Parsedown lib support ([aad67bb](https://github.com/roadiz/markdown/commit/aad67bb94a0053a1b520b40a879d4b71457cb325)) + +### Bug Fixes + +* Calling "convertToHtml()" on a League\CommonMark\MarkdownConverter class is deprecated, use "convert()" instead ([8b92632](https://github.com/roadiz/markdown/commit/8b9263292e41ddc303444583440a3702eb464f4c)) diff --git a/lib/Markdown/Makefile b/lib/Markdown/Makefile new file mode 100644 index 00000000..5f4d6d24 --- /dev/null +++ b/lib/Markdown/Makefile @@ -0,0 +1,3 @@ +test: + vendor/bin/phpcs --report=full --report-file=./report.txt -p ./src + vendor/bin/phpstan analyse -c phpstan.neon diff --git a/lib/Markdown/README.md b/lib/Markdown/README.md new file mode 100644 index 00000000..2808c9e8 --- /dev/null +++ b/lib/Markdown/README.md @@ -0,0 +1,7 @@ +# roadiz/markdown + +[![Unit tests, static analysis and code style](https://github.com/roadiz/markdown/actions/workflows/run-test.yml/badge.svg?branch=develop)](https://github.com/roadiz/markdown/actions/workflows/run-test.yml) + +**Markdown services and Twig extension for Roadiz** + +This lib defaults to `league/commonmark` Markdown parser. diff --git a/lib/Markdown/composer.json b/lib/Markdown/composer.json new file mode 100644 index 00000000..479c230d --- /dev/null +++ b/lib/Markdown/composer.json @@ -0,0 +1,36 @@ +{ + "name": "roadiz/markdown", + "description": "Markdown services and Twig extension for Roadiz", + "type": "library", + "require": { + "php": ">=8.0", + "league/commonmark": "^2.2.0", + "twig/twig": "^3.1", + "doctrine/collections": ">=1.6", + "symfony/stopwatch": "5.4.*" + }, + "require-dev": { + "squizlabs/php_codesniffer": "^3.5", + "phpstan/phpstan": "^1.5.3" + }, + "license": "MIT", + "authors": [ + { + "name": "Ambroise Maupate", + "email": "ambroise@roadiz.io", + "homepage": "https://www.roadiz.io", + "role": "Lead developer" + } + ], + "autoload": { + "psr-4": { + "RZ\\Roadiz\\Markdown\\": "src/" + } + }, + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev", + "dev-develop": "2.1.x-dev" + } + } +} diff --git a/lib/Markdown/docker-compose.yml b/lib/Markdown/docker-compose.yml new file mode 100644 index 00000000..125a0d46 --- /dev/null +++ b/lib/Markdown/docker-compose.yml @@ -0,0 +1,11 @@ +version: "3" +services: + test80: + image: roadiz/php80-runner + working_dir: /build + command: > + bash -c " + composer install -o && + make test" + volumes: + - ./:/build diff --git a/lib/Markdown/phpcs.xml.dist b/lib/Markdown/phpcs.xml.dist new file mode 100644 index 00000000..da4bfdb5 --- /dev/null +++ b/lib/Markdown/phpcs.xml.dist @@ -0,0 +1,14 @@ + + + + + + + + + + + + ./src + diff --git a/lib/Markdown/phpstan.neon b/lib/Markdown/phpstan.neon new file mode 100644 index 00000000..6f839dd2 --- /dev/null +++ b/lib/Markdown/phpstan.neon @@ -0,0 +1,12 @@ +parameters: + level: max + paths: + - src + excludePaths: + - */node_modules/* + - */bower_components/* + - */static/* + ignoreErrors: + - '#Instantiated class Memcached not found#' + - '#Instantiated class Redis not found#' + reportUnmatchedIgnoredErrors: false diff --git a/lib/Markdown/src/CommonMark.php b/lib/Markdown/src/CommonMark.php new file mode 100644 index 00000000..1abfed9f --- /dev/null +++ b/lib/Markdown/src/CommonMark.php @@ -0,0 +1,79 @@ +textConverter = $textConverter; + $this->textExtraConverter = $textExtraConverter; + $this->lineConverter = $lineConverter; + $this->stopwatch = $stopwatch; + } + + public function text(string $markdown = null): string + { + if (null === $markdown) { + return ''; + } + if (null !== $this->stopwatch) { + $this->stopwatch->start(CommonMark::class . '::text'); + } + $html = $this->textConverter->convert($markdown)->getContent(); + if (null !== $this->stopwatch) { + $this->stopwatch->stop(CommonMark::class . '::text'); + } + return $html; + } + + public function textExtra(string $markdown = null): string + { + if (null === $markdown) { + return ''; + } + if (null !== $this->stopwatch) { + $this->stopwatch->start(CommonMark::class . '::textExtra'); + } + $html = $this->textExtraConverter->convert($markdown)->getContent(); + if (null !== $this->stopwatch) { + $this->stopwatch->stop(CommonMark::class . '::textExtra'); + } + return $html; + } + + public function line(string $markdown = null): string + { + if (null === $markdown) { + return ''; + } + if (null !== $this->stopwatch) { + $this->stopwatch->start(CommonMark::class . '::line'); + } + $html = $this->lineConverter->convert($markdown)->getContent(); + if (null !== $this->stopwatch) { + $this->stopwatch->stop(CommonMark::class . '::line'); + } + return $html; + } +} diff --git a/lib/Markdown/src/MarkdownInterface.php b/lib/Markdown/src/MarkdownInterface.php new file mode 100644 index 00000000..4435a5fd --- /dev/null +++ b/lib/Markdown/src/MarkdownInterface.php @@ -0,0 +1,35 @@ +markdown = $markdown; + } + + public function getFilters(): array + { + return [ + new TwigFilter('markdown', [$this, 'markdown'], ['is_safe' => ['html']]), + new TwigFilter('inlineMarkdown', [$this, 'inlineMarkdown'], ['is_safe' => ['html']]), + new TwigFilter('inline_markdown', [$this, 'inlineMarkdown'], ['is_safe' => ['html']]), + new TwigFilter('markdownExtra', [$this, 'markdownExtra'], ['is_safe' => ['html']]), + new TwigFilter('markdown_extra', [$this, 'markdownExtra'], ['is_safe' => ['html']]), + ]; + } + + /** + * @param string|null $input + * + * @return string + */ + public function markdown(?string $input): string + { + if (null === $input) { + return ''; + } + return $this->markdown->text($input); + } + + /** + * @param string|null $input + * + * @return string + */ + public function inlineMarkdown(?string $input): string + { + if (null === $input) { + return ''; + } + return $this->markdown->line($input); + } + + /** + * @param string|null $input + * + * @return string + */ + public function markdownExtra(?string $input): string + { + if (null === $input) { + return ''; + } + return $this->markdown->textExtra($input); + } +} diff --git a/lib/Models b/lib/Models deleted file mode 160000 index cde85e3c..00000000 --- a/lib/Models +++ /dev/null @@ -1 +0,0 @@ -Subproject commit cde85e3cbf57247e956473318be1b3f23fc67a6c diff --git a/lib/Models/.editorconfig b/lib/Models/.editorconfig new file mode 100644 index 00000000..c3ccb818 --- /dev/null +++ b/lib/Models/.editorconfig @@ -0,0 +1,17 @@ +# Roadiz editor config for contributors +# http://editorconfig.org/ +root = true + +[*] +indent_style = space +indent_size = 4 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.md] +trim_trailing_whitespace = false + +[Makefile] +indent_style = tab diff --git a/lib/Models/.github/workflows/run-test.yml b/lib/Models/.github/workflows/run-test.yml new file mode 100644 index 00000000..6539ddec --- /dev/null +++ b/lib/Models/.github/workflows/run-test.yml @@ -0,0 +1,43 @@ +name: Unit tests, static analysis and code style + +on: + push: + branches: + - develop + - 'release/**' + - 'hotfix/**' + tags: ['**'] + pull_request: + types: + - opened + - reopened + - synchronize + - ready_for_review + +jobs: + run-tests: + runs-on: ubuntu-latest + strategy: + matrix: + php-version: ['8.0', '8.1'] + steps: + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + - uses: actions/checkout@v3 + - name: Cache Composer packages + id: composer-cache + uses: actions/cache@v3 + with: + path: vendor + key: ${{ runner.os }}-php-${{ matrix.php-version }}-${{ hashFiles('**/composer.lock') }} + restore-keys: | + ${{ runner.os }}-php-${{ matrix.php-version }}- + - name: Install Dependencies + run: composer install --no-scripts --no-ansi --no-interaction --no-progress + - name: Run PHPUnit tests + run: vendor/bin/phpunit -v --bootstrap=tests/bootstrap.php --whitelist ./src --coverage-clover ./build/logs/clover.xml tests/ + - name: Run PHP Code Sniffer + run: vendor/bin/phpcs -p ./src + - name: Run PHPStan + run: vendor/bin/phpstan analyse --no-progress -c phpstan.neon diff --git a/lib/Models/.gitignore b/lib/Models/.gitignore new file mode 100644 index 00000000..77a54b06 --- /dev/null +++ b/lib/Models/.gitignore @@ -0,0 +1,111 @@ +# +# Roadiz +# +/pimple.json + +# Ignore Google webmaster tool verification +/google*.html + +# PHPCS report +report.txt +.php_cs.cache + +# Cloverage build folder +/build + +# Enable favicon customisation +/favicon.ico + +# Apache files +.htpasswd +.htaccess + +# Some old css tricks for IE +*.htc + +# Created by https://www.gitignore.io + +### SublimeText ### +# cache files for sublime text +*.tmlanguage.cache +*.tmPreferences.cache +*.stTheme.cache + +# workspace files are user-specific +*.sublime-workspace + +# project files should be checked into the repository, unless a significant +# proportion of contributors will probably not be using SublimeText +# *.sublime-project + +# sftp configuration file +sftp-config.json + + +### Composer ### +composer.phar +/vendor + +# Commit your application's lock file http://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file +# You may choose to ignore a library lock file http://getcomposer.org/doc/02-libraries.md#lock-file +composer.lock + +### Node ### +# Logs +logs +*.log + +# Except for logs folder +!/logs + +# Runtime data +pids +*.pid +*.seed + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release + +# Dependency directory +# https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git +node_modules + +### Vagrant ### +/Vagrantfile +.vagrant/ + +### Docker ### +/docker-compose.yml + +### grunt ### +# Grunt usually compiles files inside this directory +dist/ + +# Grunt usually preprocesses files such as coffeescript, compass... inside the .tmp directory +.tmp/ +testDir/ + +## PHPStorm +.idea + +## XDebug profile folder +/.xdebug + +### Bower ### +bower_components +.bower-cache +.bower-registry +.bower-tmp +/.phpcs-cache diff --git a/lib/Models/.travis.yml b/lib/Models/.travis.yml new file mode 100644 index 00000000..56b6cd57 --- /dev/null +++ b/lib/Models/.travis.yml @@ -0,0 +1,17 @@ +language: php +sudo: required +php: + - 7.4 + - 8.0 + - 8.1 + - nightly +install: + - curl -s http://getcomposer.org/installer | php + - php composer.phar install --dev --no-interaction +script: + - vendor/bin/phpunit -v --bootstrap=tests/bootstrap.php --whitelist ./src --coverage-clover ./build/logs/clover.xml tests/ + - vendor/bin/phpcs --report=full --report-file=./report.txt -p ./src + - vendor/bin/phpstan analyse -c phpstan.neon +jobs: + allow_failures: + - php: nightly diff --git a/lib/Models/CHANGELOG.md b/lib/Models/CHANGELOG.md new file mode 100644 index 00000000..39e8c1a9 --- /dev/null +++ b/lib/Models/CHANGELOG.md @@ -0,0 +1,13 @@ +## 2.0.1 (2022-06-29) + +### Bug Fixes + +* TranslationInterface must extend PersistableInterface ([9b397c1](https://github.com/roadiz/models/commit/9b397c1870706bfdc75758c45ee19c854a5a7726)) + +## 2.0.0 (2022-06-28) + +### Features + +* Always use JSON Doctrine type for Collections and Multiple value fields ([2a5ccd1](https://github.com/roadiz/models/commit/2a5ccd1d4d218efaa8ea22b0debdc9f72cd2246e)) +* Updated dependencies ([9cd7336](https://github.com/roadiz/models/commit/9cd7336c622490d1fa25225a19334d6d14d142aa)) + diff --git a/lib/Models/LICENSE.md b/lib/Models/LICENSE.md new file mode 100644 index 00000000..d4d8a009 --- /dev/null +++ b/lib/Models/LICENSE.md @@ -0,0 +1,9 @@ +The MIT License (MIT) + +Copyright © 2023 Ambroise Maupate + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/lib/Models/Makefile b/lib/Models/Makefile new file mode 100644 index 00000000..b90c2a56 --- /dev/null +++ b/lib/Models/Makefile @@ -0,0 +1,10 @@ +test: + vendor/bin/phpunit -v --bootstrap=tests/bootstrap.php --whitelist ./src --coverage-clover ./build/logs/clover.xml tests/ + vendor/bin/phpcbf -p ./src + vendor/bin/phpstan analyse -c phpstan.neon + +phpcs: + vendor/bin/phpcs --report=full --report-file=./report.txt -p ./src + +phpcbf: + vendor/bin/phpcbf -p ./src diff --git a/lib/Models/README.md b/lib/Models/README.md new file mode 100644 index 00000000..2903d363 --- /dev/null +++ b/lib/Models/README.md @@ -0,0 +1,3 @@ +# Roadiz models blueprints + +[![Unit tests, static analysis and code style](https://github.com/roadiz/models/actions/workflows/run-test.yml/badge.svg?branch=develop)](https://github.com/roadiz/models/actions/workflows/run-test.yml) diff --git a/lib/Models/composer.json b/lib/Models/composer.json new file mode 100644 index 00000000..911698f7 --- /dev/null +++ b/lib/Models/composer.json @@ -0,0 +1,53 @@ +{ + "name": "roadiz/models", + "description": "Roadiz base models for entities.", + "license": "MIT", + "type": "library", + "keywords": [ + "roadiz", + "cms", + "backoffice", + "rezo zero" + ], + "authors": [ + { + "name": "Ambroise Maupate", + "email": "ambroise@roadiz.io", + "homepage": "http://www.roadiz.io", + "role": "Lead developer" + } + ], + "require": { + "php": ">=8.0", + "doctrine/orm": "^2.14.1", + "jms/serializer": "^3.9.0", + "symfony/string": "5.4.*", + "symfony/translation-contracts": "^2.3", + "symfony/http-foundation": "5.4.*", + "symfony/serializer": "5.4.*", + "symfony/validator": "5.4.*" + }, + "require-dev": { + "phpunit/phpunit": "^9.5", + "php-coveralls/php-coveralls": "^2.4", + "squizlabs/php_codesniffer": "^3.5", + "phpstan/phpstan": "^1.5.3" + }, + "autoload": { + "psr-4": { + "RZ\\": "src/" + } + }, + "config": { + "allow-plugins": { + "composer/package-versions-deprecated": true, + "php-http/discovery": false + } + }, + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev", + "dev-develop": "2.1.x-dev" + } + } +} diff --git a/lib/Models/phpcs.xml.dist b/lib/Models/phpcs.xml.dist new file mode 100644 index 00000000..19bff0cc --- /dev/null +++ b/lib/Models/phpcs.xml.dist @@ -0,0 +1,13 @@ + + + + + + + + + + + src/ + diff --git a/lib/Models/phpstan.neon b/lib/Models/phpstan.neon new file mode 100644 index 00000000..5f1941e6 --- /dev/null +++ b/lib/Models/phpstan.neon @@ -0,0 +1,15 @@ +parameters: + level: max + paths: + - src + excludePaths: + - */node_modules/* + - */bower_components/* + - */static/* + ignoreErrors: + - '#unknown class Parsedown(Extra)?#' + - '#Instantiated class Memcached not found#' + - '#Instantiated class Redis not found#' + reportUnmatchedIgnoredErrors: false + checkGenericClassInNonGenericObjectType: false + checkMissingIterableValueType: false diff --git a/lib/Models/src/Roadiz/Bag/LazyParameterBag.php b/lib/Models/src/Roadiz/Bag/LazyParameterBag.php new file mode 100644 index 00000000..4e9d6cd2 --- /dev/null +++ b/lib/Models/src/Roadiz/Bag/LazyParameterBag.php @@ -0,0 +1,122 @@ +ready = false; + } + + /** + * @param string $key + * @param mixed|null $default + * @return mixed|null + */ + public function get(string $key, $default = null) + { + if (!$this->ready) { + $this->populateParameters(); + } + + return parent::get($key, $default); + } + + /** + * @param string|null $key + * @return array + */ + public function all(string $key = null): array + { + if (!$this->ready) { + $this->populateParameters(); + } + + return parent::all(); + } + + /** + * @param string $key + * + * @return bool + */ + public function has(string $key) + { + if (!$this->ready) { + $this->populateParameters(); + } + + return parent::has($key); + } + + /** + * @return array + */ + public function keys() + { + if (!$this->ready) { + $this->populateParameters(); + } + + return parent::keys(); + } + + /** + * @return \ArrayIterator + */ + #[\ReturnTypeWillChange] + public function getIterator() + { + if (!$this->ready) { + $this->populateParameters(); + } + + return parent::getIterator(); + } + + /** + * @return int + */ + #[\ReturnTypeWillChange] + public function count() + { + if (!$this->ready) { + $this->populateParameters(); + } + + return parent::count(); + } + + /** + * @param string $key + * @param null $default + * @param int $filter + * @param array $options + * + * @return mixed + */ + public function filter(string $key, $default = null, int $filter = \FILTER_DEFAULT, $options = []) + { + if (!$this->ready) { + $this->populateParameters(); + } + + return parent::filter($key, $default, $filter, $options); + } + + public function reset(): void + { + $this->parameters = []; + $this->ready = false; + } +} diff --git a/lib/Models/src/Roadiz/Core/AbstractEntities/AbstractDateTimed.php b/lib/Models/src/Roadiz/Core/AbstractEntities/AbstractDateTimed.php new file mode 100644 index 00000000..3483f9b1 --- /dev/null +++ b/lib/Models/src/Roadiz/Core/AbstractEntities/AbstractDateTimed.php @@ -0,0 +1,116 @@ +createdAt; + } + + /** + * @param DateTime|null $createdAt + * @return AbstractDateTimed + */ + public function setCreatedAt(?DateTime $createdAt) + { + $this->createdAt = $createdAt; + return $this; + } + + /** + * @return DateTime|null + */ + public function getUpdatedAt(): ?DateTime + { + return $this->updatedAt; + } + + /** + * @param DateTime|null $updatedAt + * @return AbstractDateTimed + */ + public function setUpdatedAt(?DateTime $updatedAt) + { + $this->updatedAt = $updatedAt; + return $this; + } + + protected function initAbstractDateTimed(): void + { + $this->setUpdatedAt(new DateTime("now")); + $this->setCreatedAt(new DateTime("now")); + } + + /** + * @return void + */ + #[ORM\PreUpdate] + public function preUpdate() + { + $this->setUpdatedAt(new DateTime("now")); + } + /** + * @return void + */ + #[ORM\PrePersist] + public function prePersist() + { + $this->setUpdatedAt(new DateTime("now")); + $this->setCreatedAt(new DateTime("now")); + } + /** + * Set creation and update date to *now*. + * + * @return AbstractEntity + */ + public function resetDates() + { + $this->setCreatedAt(new DateTime("now")); + $this->setUpdatedAt(new DateTime("now")); + + return $this; + } +} diff --git a/lib/Models/src/Roadiz/Core/AbstractEntities/AbstractDateTimedPositioned.php b/lib/Models/src/Roadiz/Core/AbstractEntities/AbstractDateTimedPositioned.php new file mode 100644 index 00000000..bcee85ea --- /dev/null +++ b/lib/Models/src/Roadiz/Core/AbstractEntities/AbstractDateTimedPositioned.php @@ -0,0 +1,34 @@ +id; + } + + /** + * @param int|string|null $id + * @return AbstractEntity + */ + public function setId(int|string|null $id): self + { + $this->id = $id; + return $this; + } +} diff --git a/lib/Models/src/Roadiz/Core/AbstractEntities/AbstractField.php b/lib/Models/src/Roadiz/Core/AbstractEntities/AbstractField.php new file mode 100644 index 00000000..737baf37 --- /dev/null +++ b/lib/Models/src/Roadiz/Core/AbstractEntities/AbstractField.php @@ -0,0 +1,848 @@ + + * @internal + */ + #[SymfonySerializer\Ignore] + public static array $typeToHuman = [ + AbstractField::STRING_T => 'string.type', + AbstractField::DATETIME_T => 'date-time.type', + AbstractField::DATE_T => 'date.type', + AbstractField::TEXT_T => 'text.type', + AbstractField::MARKDOWN_T => 'markdown.type', + AbstractField::BOOLEAN_T => 'boolean.type', + AbstractField::INTEGER_T => 'integer.type', + AbstractField::DECIMAL_T => 'decimal.type', + AbstractField::EMAIL_T => 'email.type', + AbstractField::ENUM_T => 'single-choice.type', + AbstractField::MULTIPLE_T => 'multiple-choice.type', + AbstractField::DOCUMENTS_T => 'documents.type', + AbstractField::NODES_T => 'nodes.type', + AbstractField::CHILDREN_T => 'children-nodes.type', + AbstractField::COLOUR_T => 'colour.type', + AbstractField::GEOTAG_T => 'geographic.coordinates.type', + AbstractField::CUSTOM_FORMS_T => 'custom-forms.type', + AbstractField::MULTI_GEOTAG_T => 'multiple.geographic.coordinates.type', + AbstractField::JSON_T => 'json.type', + AbstractField::CSS_T => 'css.type', + AbstractField::COUNTRY_T => 'country.type', + AbstractField::YAML_T => 'yaml.type', + AbstractField::MANY_TO_MANY_T => 'many-to-many.type', + AbstractField::MANY_TO_ONE_T => 'many-to-one.type', + AbstractField::SINGLE_PROVIDER_T => 'single-provider.type', + AbstractField::MULTI_PROVIDER_T => 'multiple-provider.type', + AbstractField::COLLECTION_T => 'collection.type', + ]; + /** + * Associates abstract field type to a Doctrine type. + * + * @var array + * @internal + */ + #[SymfonySerializer\Ignore] + public static array $typeToDoctrine = [ + AbstractField::STRING_T => 'string', + AbstractField::DATETIME_T => 'datetime', + AbstractField::DATE_T => 'datetime', + AbstractField::RICHTEXT_T => 'text', + AbstractField::TEXT_T => 'text', + AbstractField::MARKDOWN_T => 'text', + AbstractField::BOOLEAN_T => 'boolean', + AbstractField::INTEGER_T => 'integer', + AbstractField::DECIMAL_T => 'decimal', + AbstractField::EMAIL_T => 'string', + AbstractField::ENUM_T => 'string', + AbstractField::MULTIPLE_T => 'json', + AbstractField::DOCUMENTS_T => null, + AbstractField::NODES_T => null, + AbstractField::CHILDREN_T => null, + AbstractField::COLOUR_T => 'string', + AbstractField::GEOTAG_T => 'json', + AbstractField::CUSTOM_FORMS_T => null, + AbstractField::MULTI_GEOTAG_T => 'json', + AbstractField::JSON_T => 'text', + AbstractField::CSS_T => 'text', + AbstractField::COUNTRY_T => 'string', + AbstractField::YAML_T => 'text', + AbstractField::MANY_TO_MANY_T => null, + AbstractField::MANY_TO_ONE_T => null, + AbstractField::SINGLE_PROVIDER_T => 'string', + AbstractField::MULTI_PROVIDER_T => 'json', + AbstractField::COLLECTION_T => 'json', + ]; + + /** + * List searchable fields types in a searchEngine such as Solr. + * + * @var array + * @internal + */ + #[SymfonySerializer\Ignore] + protected static array $searchableTypes = [ + AbstractField::STRING_T, + AbstractField::RICHTEXT_T, + AbstractField::TEXT_T, + AbstractField::MARKDOWN_T, + ]; + + #[ + ORM\Column(name: "group_name", type: "string", nullable: true), + Assert\Length(max: 250), + SymfonySerializer\Groups(["node_type", "setting"]), + Serializer\Groups(["node_type", "setting"]), + Serializer\Type("string"), + Serializer\Expose + ] + protected ?string $groupName = null; + + #[ + ORM\Column(name: "group_name_canonical", type: "string", nullable: true), + Serializer\Groups(["node_type", "setting"]), + SymfonySerializer\Groups(["node_type", "setting"]), + Assert\Length(max: 250), + Serializer\Type("string"), + Serializer\Expose + ] + protected ?string $groupNameCanonical = null; + + #[ + ORM\Column(type: "string"), + Serializer\Expose, + Serializer\Groups(["node_type", "setting"]), + SymfonySerializer\Groups(["node_type", "setting"]), + Assert\Length(max: 250), + Serializer\Type("string"), + Assert\NotBlank(), + Assert\NotNull() + ] + protected string $name; + + #[ + ORM\Column(type: "string"), + Serializer\Expose, + Serializer\Groups(["node_type", "setting"]), + Serializer\Type("string"), + SymfonySerializer\Groups(["node_type", "setting"]), + Assert\Length(max: 250), + Assert\NotBlank(), + Assert\NotNull() + ] + protected ?string $label; + + #[ + ORM\Column(type: "string", nullable: true), + Serializer\Expose, + Serializer\Groups(["node_type", "setting"]), + SymfonySerializer\Groups(["node_type", "setting"]), + Assert\Length(max: 250), + Serializer\Type("string") + ] + protected ?string $placeholder = null; + + #[ + ORM\Column(type: "text", nullable: true), + Serializer\Expose, + Serializer\Groups(["node_type", "setting"]), + SymfonySerializer\Groups(["node_type", "setting"]), + Serializer\Type("string") + ] + protected ?string $description = null; + + #[ + ORM\Column(name: "default_values", type: "text", nullable: true), + Serializer\Groups(["node_type", "setting"]), + SymfonySerializer\Groups(["node_type", "setting"]), + Serializer\Type("string"), + Serializer\Expose + ] + protected ?string $defaultValues = null; + + #[ + ORM\Column(type: "integer"), + Serializer\Groups(["node_type", "setting"]), + SymfonySerializer\Groups(["node_type", "setting"]), + Serializer\Type("int"), + Serializer\Expose + ] + protected int $type = AbstractField::STRING_T; + + /** + * If current field data should be expanded (for choices and country types). + */ + #[ + ORM\Column(name: "expanded", type: "boolean", nullable: false, options: ["default" => false]), + Serializer\Groups(["node_type", "setting"]), + SymfonySerializer\Groups(["node_type", "setting"]), + Serializer\Type("bool"), + Serializer\Expose + ] + protected bool $expanded = false; + + public function __construct() + { + $this->label = 'Untitled field'; + $this->name = 'untitled_field'; + } + + /** + * @return string Camel case field name + */ + public function getVarName(): string + { + return StringHandler::camelCase($this->getName()); + } + + /** + * @return string $name + */ + public function getName(): string + { + return $this->name; + } + + /** + * @param string|null $name + * + * @return $this + */ + public function setName(?string $name) + { + $this->name = StringHandler::variablize($name ?? ''); + return $this; + } + + /** + * @return string Camel case getter method name + */ + public function getGetterName(): string + { + return StringHandler::camelCase('get ' . $this->getName()); + } + + /** + * @return string Camel case setter method name + */ + public function getSetterName(): string + { + return StringHandler::camelCase('set ' . $this->getName()); + } + + /** + * @return string + */ + public function getLabel(): string + { + return $this->label ?? ''; + } + + /** + * @param string|null $label + * + * @return self + */ + public function setLabel(?string $label) + { + $this->label = $label ?? ''; + return $this; + } + + /** + * @return string|null + */ + public function getPlaceholder(): ?string + { + return $this->placeholder; + } + + /** + * @param string|null $placeholder + * @return AbstractField + */ + public function setPlaceholder(?string $placeholder) + { + $this->placeholder = $placeholder; + return $this; + } + + /** + * @return string|null + */ + public function getDescription(): ?string + { + return $this->description; + } + + /** + * @param string|null $description + * + * @return $this + */ + public function setDescription(?string $description) + { + $this->description = $description; + return $this; + } + + /** + * @return string|null + */ + public function getDefaultValues(): ?string + { + return $this->defaultValues; + } + + /** + * @param string|null $defaultValues + * + * @return $this + */ + public function setDefaultValues(?string $defaultValues) + { + $this->defaultValues = $defaultValues; + return $this; + } + + /** + * @return string + */ + public function getTypeName(): string + { + if (!key_exists($this->getType(), static::$typeToHuman)) { + throw new \InvalidArgumentException($this->getType() . ' cannot be mapped to human label.'); + } + return static::$typeToHuman[$this->type]; + } + + /** + * @return int + */ + public function getType(): int + { + return $this->type; + } + + /** + * @param int $type + * + * @return $this + */ + public function setType(int $type) + { + $this->type = $type; + return $this; + } + + /** + * @return string + */ + public function getDoctrineType(): string + { + if (!key_exists($this->getType(), static::$typeToDoctrine)) { + throw new \InvalidArgumentException($this->getType() . ' cannot be mapped to Doctrine.'); + } + return static::$typeToDoctrine[$this->getType()] ?? ''; + } + + /** + * @return bool Is node type field virtual, it's just an association, no doctrine field created + */ + public function isVirtual(): bool + { + return static::$typeToDoctrine[$this->getType()] === null; + } + + /** + * @return bool Is node type field searchable + */ + public function isSearchable(): bool + { + return in_array($this->getType(), static::$searchableTypes); + } + + /** + * Gets the value of groupName. + * + * @return string|null + */ + public function getGroupName(): ?string + { + return $this->groupName; + } + + /** + * Sets the value of groupName. + * + * @param string|null $groupName the group name + * @return static + */ + public function setGroupName(?string $groupName) + { + if (null === $groupName) { + $this->groupName = null; + $this->groupNameCanonical = null; + } else { + $this->groupName = trim(strip_tags($groupName)); + $this->groupNameCanonical = StringHandler::slugify($this->getGroupName()); + } + return $this; + } + + /** + * @return string|null + */ + public function getGroupNameCanonical(): ?string + { + return $this->groupNameCanonical; + } + + /** + * @return bool + */ + public function isExpanded(): bool + { + return $this->expanded; + } + + /** + * @param bool $expanded + * @return AbstractField + */ + public function setExpanded(bool $expanded) + { + $this->expanded = $expanded; + return $this; + } + + /** + * @return bool + */ + public function isString(): bool + { + return $this->getType() === static::STRING_T; + } + + /** + * @return bool + */ + public function isText(): bool + { + return $this->getType() === static::TEXT_T; + } + + /** + * @return bool + */ + public function isDate(): bool + { + return $this->getType() === static::DATE_T; + } + + /** + * @return bool + */ + public function isDateTime(): bool + { + return $this->getType() === static::DATETIME_T; + } + + /** + * @return bool + */ + public function isRichText(): bool + { + return $this->getType() === static::RICHTEXT_T; + } + + /** + * @return bool + */ + public function isMarkdown(): bool + { + return $this->getType() === static::MARKDOWN_T; + } + + /** + * @return bool + */ + public function isBool(): bool + { + return $this->isBoolean(); + } + + /** + * @return bool + */ + public function isBoolean(): bool + { + return $this->getType() === static::BOOLEAN_T; + } + + /** + * @return bool + */ + public function isInteger(): bool + { + return $this->getType() === static::INTEGER_T; + } + + /** + * @return bool + */ + public function isDecimal(): bool + { + return $this->getType() === static::DECIMAL_T; + } + + /** + * @return bool + */ + public function isEmail(): bool + { + return $this->getType() === static::EMAIL_T; + } + + /** + * @return bool + */ + public function isDocuments(): bool + { + return $this->getType() === static::DOCUMENTS_T; + } + + /** + * @return bool + */ + public function isPassword(): bool + { + return $this->getType() === static::PASSWORD_T; + } + + /** + * @return bool + */ + public function isColor(): bool + { + return $this->isColour(); + } + + /** + * @return bool + */ + public function isColour(): bool + { + return $this->getType() === static::COLOUR_T; + } + + /** + * @return bool + */ + public function isGeoTag(): bool + { + return $this->getType() === static::GEOTAG_T; + } + + /** + * @return bool + */ + public function isNodes(): bool + { + return $this->getType() === static::NODES_T; + } + + /** + * @return bool + */ + public function isUser(): bool + { + return $this->getType() === static::USER_T; + } + + /** + * @return bool + */ + public function isEnum(): bool + { + return $this->getType() === static::ENUM_T; + } + + /** + * @return bool + */ + public function isChildrenNodes(): bool + { + return $this->getType() === static::CHILDREN_T; + } + + /** + * @return bool + */ + public function isCustomForms(): bool + { + return $this->getType() === static::CUSTOM_FORMS_T; + } + + /** + * @return bool + */ + public function isMultiple(): bool + { + return $this->getType() === static::MULTIPLE_T; + } + + /** + * @return bool + */ + public function isMultiGeoTag(): bool + { + return $this->getType() === static::MULTI_GEOTAG_T; + } + + /** + * @return bool + */ + public function isJson(): bool + { + return $this->getType() === static::JSON_T; + } + + /** + * @return bool + */ + public function isYaml(): bool + { + return $this->getType() === static::YAML_T; + } + + /** + * @return bool + */ + public function isCss(): bool + { + return $this->getType() === static::CSS_T; + } + + /** + * @return bool + */ + public function isManyToMany(): bool + { + return $this->getType() === static::MANY_TO_MANY_T; + } + + /** + * @return bool + */ + public function isManyToOne(): bool + { + return $this->getType() === static::MANY_TO_ONE_T; + } + + /** + * @return bool + */ + public function isCountry(): bool + { + return $this->getType() === static::COUNTRY_T; + } + + /** + * @return bool + */ + public function isSingleProvider(): bool + { + return $this->getType() === static::SINGLE_PROVIDER_T; + } + + /** + * @return bool + */ + public function isMultipleProvider(): bool + { + return $this->isMultiProvider(); + } + + /** + * @return bool + */ + public function isMultiProvider(): bool + { + return $this->getType() === static::MULTI_PROVIDER_T; + } + + /** + * @return bool + */ + public function isCollection(): bool + { + return $this->getType() === static::COLLECTION_T; + } +} diff --git a/lib/Models/src/Roadiz/Core/AbstractEntities/AbstractHuman.php b/lib/Models/src/Roadiz/Core/AbstractEntities/AbstractHuman.php new file mode 100644 index 00000000..9e8fd691 --- /dev/null +++ b/lib/Models/src/Roadiz/Core/AbstractEntities/AbstractHuman.php @@ -0,0 +1,244 @@ +email; + } + + /** + * @param string|null $email + * + * @return $this + */ + public function setEmail(?string $email) + { + if (filter_var($email ?? '', FILTER_VALIDATE_EMAIL) !== false) { + $this->email = $email; + } + + return $this; + } + + /** + * @return string|null + */ + public function getFirstName(): ?string + { + return $this->firstName; + } + + /** + * @param string|null $firstName + * + * @return $this + */ + public function setFirstName(?string $firstName) + { + $this->firstName = $firstName; + return $this; + } + + /** + * @return string|null + */ + public function getLastName(): ?string + { + return $this->lastName; + } + + /** + * @param string|null $lastName + * + * @return $this + */ + public function setLastName(?string $lastName) + { + $this->lastName = $lastName; + return $this; + } + + /** + * @return string|null + */ + public function getCompany(): ?string + { + return $this->company; + } + + /** + * @param string|null $company + * + * @return $this + */ + public function setCompany(?string $company) + { + $this->company = $company; + return $this; + } + + /** + * @return string|null + */ + public function getJob(): ?string + { + return $this->job; + } + + /** + * @param string|null $job + * + * @return $this + */ + public function setJob(?string $job) + { + $this->job = $job; + return $this; + } + + /** + * @return DateTime|null + */ + public function getBirthday(): ?DateTime + { + return $this->birthday; + } + /** + * @param DateTime|null $birthday + * + * @return $this + */ + public function setBirthday(?DateTime $birthday = null) + { + $this->birthday = $birthday; + return $this; + } + + /** + * @return string|null + */ + public function getPhone(): ?string + { + return $this->phone; + } + + /** + * @param string|null $phone + * + * @return self + */ + public function setPhone(?string $phone) + { + $this->phone = $phone; + return $this; + } + + /** + * @return string|null + */ + public function getPublicName(): ?string + { + return $this->publicName; + } + + /** + * @param string|null $publicName + */ + public function setPublicName(?string $publicName): void + { + $this->publicName = $publicName; + } +} diff --git a/lib/Models/src/Roadiz/Core/AbstractEntities/AbstractPositioned.php b/lib/Models/src/Roadiz/Core/AbstractEntities/AbstractPositioned.php new file mode 100644 index 00000000..db394b15 --- /dev/null +++ b/lib/Models/src/Roadiz/Core/AbstractEntities/AbstractPositioned.php @@ -0,0 +1,32 @@ + + */ + public function getChildren(): Collection; + + /** + * @param static $child + * @return $this + */ + public function addChild(LeafInterface $child): static; + + /** + * @param static $child + * @return $this + */ + public function removeChild(LeafInterface $child): static; + + /** + * Do not add static return type because of Doctrine Proxy. + * + * @return static|null + */ + public function getParent(): ?LeafInterface; + + /** + * @return static[] + */ + public function getParents(): array; + + /** + * @param static|null $parent + * @return $this + */ + public function setParent(?LeafInterface $parent = null): static; + + /** + * Gets the leaf depth. + * + * @return int + */ + public function getDepth(): int; +} diff --git a/lib/Models/src/Roadiz/Core/AbstractEntities/LeafTrait.php b/lib/Models/src/Roadiz/Core/AbstractEntities/LeafTrait.php new file mode 100644 index 00000000..5b586a3d --- /dev/null +++ b/lib/Models/src/Roadiz/Core/AbstractEntities/LeafTrait.php @@ -0,0 +1,117 @@ +children; + } + + /** + * @param Collection $children + * @return $this + */ + public function setChildren(Collection $children): static + { + $this->children = $children; + /** @var static $child */ + foreach ($this->children as $child) { + $child->setParent($this); + } + return $this; + } + + /** + * @param static $child + * @return $this + */ + public function addChild(LeafInterface $child): static + { + if (!$this->getChildren()->contains($child)) { + $this->getChildren()->add($child); + $child->setParent($this); + } + + return $this; + } + /** + * @param static $child + * @return $this + */ + public function removeChild(LeafInterface $child): static + { + if ($this->getChildren()->contains($child)) { + $this->getChildren()->removeElement($child); + $child->setParent(null); + } + + return $this; + } + + /* + * Do not add static return type because of Doctrine Proxy. + */ + /** + * @return static|null + */ + public function getParent(): ?LeafInterface + { + /** @phpstan-ignore-next-line */ + return $this->parent; + } + + /** + * @param static|null $parent + * @return $this + */ + public function setParent(?LeafInterface $parent = null): static + { + if ($parent === $this) { + throw new \InvalidArgumentException('An entity cannot have itself as a parent.'); + } + + $this->parent = $parent; + $this->parent?->addChild($this); + + return $this; + } + + /** + * @return static[] + */ + public function getParents(): array + { + $parentsArray = []; + $parent = $this; + + do { + $parent = $parent->getParent(); + if ($parent !== null) { + $parentsArray[] = $parent; + } + } while ($parent !== null); + + return array_reverse($parentsArray); + } + + /** + * Gets the nodes' depth. + * + * @return int + */ + public function getDepth(): int + { + if ($this->getParent() === null) { + return 0; + } + return $this->getParent()->getDepth() + 1; + } +} diff --git a/lib/Models/src/Roadiz/Core/AbstractEntities/PersistableInterface.php b/lib/Models/src/Roadiz/Core/AbstractEntities/PersistableInterface.php new file mode 100644 index 00000000..2fad6286 --- /dev/null +++ b/lib/Models/src/Roadiz/Core/AbstractEntities/PersistableInterface.php @@ -0,0 +1,20 @@ +position; + } + + /** + * Set position as a float to enable increment and decrement by O.5 + * to insert a node between two others. + * + * @param float $newPosition + * @return $this + */ + public function setPosition(float $newPosition) + { + if ($newPosition > -1) { + $this->position = $newPosition; + } + + return $this; + } + + /** + * @param mixed $other + * @return int + */ + public function compareTo($other): int + { + if ($other instanceof PositionedInterface) { + return $this->getPosition() <=> $other->getPosition(); + } + throw new \LogicException('Cannot compare object which does not implement ' . PositionedInterface::class); + } +} diff --git a/lib/Models/src/Roadiz/Core/AbstractEntities/TranslationInterface.php b/lib/Models/src/Roadiz/Core/AbstractEntities/TranslationInterface.php new file mode 100644 index 00000000..ee2d01e4 --- /dev/null +++ b/lib/Models/src/Roadiz/Core/AbstractEntities/TranslationInterface.php @@ -0,0 +1,80 @@ +handlerFactory = $handlerFactory; + } + + /** + * {@inheritdoc} + */ + public function getSubscribedEvents(): array + { + return [ + Events::prePersist, + ]; + } + + /** + * @param LifecycleEventArgs $event + * @return void + */ + public function prePersist(LifecycleEventArgs $event) + { + $entity = $event->getEntity(); + if ($entity instanceof AbstractEntity && $entity instanceof LeafInterface) { + /* + * Automatically set position only if not manually set before. + */ + try { + $handler = $this->handlerFactory->getHandler($entity); + + if ($entity->getPosition() === 0.0) { + /* + * Get the last index after last tag in parent + */ + $lastPosition = $handler->cleanPositions(false); + if ($lastPosition > 1 && null !== $entity->getParent()) { + /* + * Need to decrement position because current tag is already + * in parent's children collection count. + */ + $entity->setPosition($lastPosition - 1); + } else { + $entity->setPosition($lastPosition); + } + } elseif ($entity->getPosition() === 0.5) { + /* + * Position is set to 0.5, so we need to + * shift all tags to the bottom. + */ + $handler->cleanPositions(true); + } + } catch (\InvalidArgumentException $e) { + } + } + } +} diff --git a/lib/Models/src/Roadiz/Core/Handlers/AbstractHandler.php b/lib/Models/src/Roadiz/Core/Handlers/AbstractHandler.php new file mode 100644 index 00000000..fa5a7084 --- /dev/null +++ b/lib/Models/src/Roadiz/Core/Handlers/AbstractHandler.php @@ -0,0 +1,49 @@ +objectManager; + } + + /** + * @param ObjectManager $objectManager + * @return static + */ + public function setObjectManager(ObjectManager $objectManager) + { + $this->objectManager = $objectManager; + return $this; + } + + /** + * @param ObjectManager $objectManager + */ + public function __construct(ObjectManager $objectManager) + { + $this->objectManager = $objectManager; + } + + /** + * Clean positions for current entity siblings. + * + * @param bool $setPositions + * @return float Return the next position after the **last** entity + */ + public function cleanPositions(bool $setPositions = true): float + { + return 1; + } +} diff --git a/lib/Models/src/Roadiz/Core/Handlers/HandlerFactoryInterface.php b/lib/Models/src/Roadiz/Core/Handlers/HandlerFactoryInterface.php new file mode 100644 index 00000000..2b0e53b6 --- /dev/null +++ b/lib/Models/src/Roadiz/Core/Handlers/HandlerFactoryInterface.php @@ -0,0 +1,16 @@ +ascii() + ->toString() + ; + } + + /** + * Transform to lowercase and replace every non-alpha character with a dash. + * + * @param string|null $string + * @return string + */ + public static function slugify(?string $string): string + { + if (null === $string) { + return ''; + } + $slugger = new AsciiSlugger(); + return $slugger->slug($string)->lower()->toString(); + } + /** + * Transform a string for use as a classname. + * + * @param string|null $string + * + * @return string Classified string + */ + public static function classify(?string $string): string + { + if (null === $string) { + return ''; + } + + return (new UnicodeString($string)) + ->ascii() + ->camel() + ->title() + ->toString() + ; + } + /** + * Transform to lowercase and replace every non-alpha character with an underscore. + * + * @param string|null $string + * + * @return string + */ + public static function cleanForFilename(?string $string): string + { + if (null === $string) { + return ''; + } + + return (new UnicodeString($string)) + ->ascii() + ->trim() + ->replaceMatches('#([^a-zA-Z0-9\.]+)#', '_') + ->lower() + ->toString() + ; + } + + /** + * Transform to lowercase and replace every non-alpha character with an underscore. + * + * @param string|null $string + * + * @return string + */ + public static function variablize(?string $string): string + { + if (null === $string) { + return ''; + } + + return (new UnicodeString($string)) + ->ascii() + ->snake() + ->lower() + ->trim('-') + ->trim('_') + ->trim() + ->toString() + ; + } + + /** + * Transform to camelcase. + * + * @param string|null $string + * + * @return string + */ + public static function camelCase(?string $string): string + { + if (null === $string) { + return ''; + } + + return (new UnicodeString($string)) + ->ascii() + ->camel() + ->trim('-') + ->trim('_') + ->trim() + ->toString() + ; + } + + + /** + * Encode a string using website security secret. + * + * @param string|null $value String to encode + * @param string|null $secret Secret salt + * + * @return string + * @throws \InvalidArgumentException + */ + public static function encodeWithSecret(?string $value, ?string $secret): string + { + $secret = trim($secret ?? ''); + + if (!empty($secret)) { + $secret = crypt($secret, $secret); + return base64_encode($secret . base64_encode(strip_tags($value ?? ''))); + } else { + throw new \InvalidArgumentException("You cannot encode with an empty salt. Did you enter a secret security phrase in your conf/config.json file?", 1); + } + } + + /** + * Decode a string using website security secret. + * + * @param string|null $value Salted base64 string + * @param string|null $secret Secret salt + * + * @return string + * @throws \InvalidArgumentException + */ + public static function decodeWithSecret(?string $value, ?string $secret): string + { + $secret = trim($secret ?? ''); + + if (!empty($secret)) { + $secret = crypt($secret, $secret); + $salted = base64_decode($value ?? ''); + + $nonSalted = str_replace($secret, "", $salted); + + return base64_decode($nonSalted); + } else { + throw new \InvalidArgumentException("You cannot encode with an empty salt. Did you enter a secret security phrase in your conf/config.json file?", 1); + } + } + + /** + * @param string $haystack + * @param string $needle + * @return bool + * @deprecated Use UnicodeString::endsWith($needle) + */ + public static function endsWith(string $haystack, string $needle): bool + { + if ($needle === '') { + return true; + } + + return (new UnicodeString($haystack)) + ->endsWith($needle) + ; + } + + /** + * @param string $search + * @param string $replace + * @param string $subject + * @return string + */ + public static function replaceLast(string $search, string $replace, string $subject): string + { + $pos = strrpos($subject, $search); + + if ($pos !== false) { + $subject = substr_replace($subject, $replace, $pos, strlen($search)); + } + + return $subject; + } +} diff --git a/lib/Models/tests/Utils/StringHandlerTest.php b/lib/Models/tests/Utils/StringHandlerTest.php new file mode 100644 index 00000000..1490e26b --- /dev/null +++ b/lib/Models/tests/Utils/StringHandlerTest.php @@ -0,0 +1,341 @@ + + */ + +use PHPUnit\Framework\TestCase; +use RZ\Roadiz\Utils\StringHandler; + +/** + * Class StringHandlerTest + */ +class StringHandlerTest extends TestCase +{ + + /** + * @dataProvider cleanForFilenameProvider + * @param $input + * @param $expected + */ + public function testCleanForFilename($input, $expected) + { + $this->assertEquals($expected, StringHandler::cleanForFilename($input)); + } + + public function cleanForFilenameProvider() + { + return [ + [ + "Les-Echos_26022015_Les-entrepreneurs-partent-à-lassaut-du-secteur-bancaire.pdf", + "les_echos_26022015_les_entrepreneurs_partent_a_lassaut_du_secteur_bancaire.pdf" + ], + [ + "Les-entrepreneurs-partent-à-lassaut-du-secteur-bancaire.pdf", + "les_entrepreneurs_partent_a_lassaut_du_secteur_bancaire.pdf" + ], + [ + "image.jpg", + "image.jpg", + ], + [ + "image with spaces.jpg", + "image_with_spaces.jpg", + ], + [ + "image/with/slashes.jpg", + "image_with_slashes.jpg", + ] + ]; + } + + /** + * @dataProvider endsWithProvider + * @param $input + * @param $wanted + * @param $expected + */ + public function testEndsWith($input, $wanted, $expected) + { + $this->assertEquals($expected, StringHandler::endsWith($input, $wanted)); + } + + public function endsWithProvider() + { + return [ + [" ", "Locale", false], + ["", "Locale", false], + ["home", "Locale", false], + ["ocale", "Locale", false], + ["testPage", "Locale", false], + ["localePage", "Locale", false], + ["testLocalePage", "Locale", false], + ["testPageLocale", "Locale", true], + ["testPagelocale", "Locale", false], + ["testPageGateau", "Locale", false], + ["testPage", "", true], + ["LocaletestPage", "Locale", false], + ]; + } + + /** + * @dataProvider replaceLastProvider + * @param $input + * @param $wanted + * @param $expected + */ + public function testReplaceLast($input, $wanted, $expected) + { + $this->assertEquals($expected, StringHandler::replaceLast($wanted, "", $input)); + } + + /** + * @return array + */ + public function replaceLastProvider() + { + return [ + ["testPage", "Locale", "testPage"], + ["localePage", "Locale", "localePage"], + ["testLocalePage", "Locale", "testPage"], + ["testPageLocale", "Locale", "testPage"], + ["testPagelocale", "Locale", "testPagelocale"], + ["testPageGateau", "Locale", "testPageGateau"], + ["testPage", "", "testPage"], + ["LocalePage", "Locale", "Page"], + ]; + } + + /** + * @dataProvider removeDiacriticsProvider + * @param $input + * @param $expected + */ + public function testRemoveDiacritics($input, $expected) + { + // Assert + $this->assertEquals($expected, StringHandler::removeDiacritics($input)); + } + + /** + * @return array + */ + public function removeDiacriticsProvider() + { + return [ + ["à", "a"], + ["é", "e"], + ["À", "A"], + ["É", "E"], + ["œ", "oe"], + ["ç", "c"], + ["__à", "__a"], + ["--é", "--e"], + [ + "Les-echos_26022015_Les-entrepreneurs-partent-à-lassaut-du-secteur-bancaire.pdf", + "Les-echos_26022015_Les-entrepreneurs-partent-a-lassaut-du-secteur-bancaire.pdf" + ], + ]; + } + + /** + * @dataProvider variablizeProvider + * @param $input + * @param $expected + */ + public function testVariablize($input, $expected) + { + // Assert + $this->assertEquals($expected, StringHandler::variablize($input)); + } + + /** + * @return array + */ + public function variablizeProvider() + { + return [ + ["à", "a"], + ["é", "e"], + ["À", "a"], + ["É", "e"], + ["œ", "oe"], + ["ç", "c"], + ["__à", "a"], + ["--é", "e"], + ["Ligula $* _--Egestas Mattis Nullam$* _ ", "ligula_egestas_mattis_nullam"], + ["Véèsti buœlum Rïsus+", "veesti_buoelum_risus"], + ["J'aime les sushis!", "j_aime_les_sushis"], + ["J’aime les sushis!", "j_aime_les_sushis"], + ["J'aime les\n sushis!\t\n", "j_aime_les_sushis"], + ["?header_image", "header_image"], + ["JAime les_sushis", "j_aime_les_sushis"], + ["Ébène", "ebene"], + ["ébène", "ebene"], + ]; + } + + /** + * @dataProvider classifyProvider + * @param $input + * @param $expected + */ + public function testClassify($input, $expected) + { + // Assert + $this->assertEquals($expected, StringHandler::classify($input)); + } + + /** + * @return array + */ + public function classifyProvider() + { + return [ + ["Ligula $* _--Egestas Mattis Nullam", "LigulaEgestasMattisNullam"], + ["Véèsti buœlum Rïsus", "VeestiBuoelumRisus"], + ["J'aime les sushis", "JAimeLesSushis"], + ["header_image", "HeaderImage"], + ["JAime les_sushis", "JAimeLesSushis"], + ]; + } + + /** + * @dataProvider camelCaseProvider + * @param $input + * @param $expected + */ + public function testCamelCase($input, $expected) + { + // Assert + $this->assertEquals($expected, StringHandler::camelcase($input)); + } + + /** + * @return array + */ + public function camelCaseProvider() + { + return [ + ["Ligula $* _--Egestas Mattis Nullam", "ligulaEgestasMattisNullam"], + ["Véèsti buœlum Rïsus", "veestiBuoelumRisus"], + ["J'aime les sushis", "jAimeLesSushis"], + ["header_image", "headerImage"], + ["JAime les_sushis", "jAimeLesSushis"], + ]; + } + + /** + * @dataProvider slugifyProvider + * @param $input + * @param $expected + */ + public function testSlugify($input, $expected) + { + // Assert + $this->assertEquals($expected, StringHandler::slugify($input)); + } + + /** + * @return array + */ + public function slugifyProvider() + { + return [ + ["Ligula $* _--Egestas Mattis Nullam$* _ ", "ligula-egestas-mattis-nullam"], + ["Véèsti buœlum Rïsus+", "veesti-buoelum-risus"], + ["veesti-buoelum-risus", "veesti-buoelum-risus"], + ["J'aime les sushis!", "j-aime-les-sushis"], + ["J’aime les sushis!", "j-aime-les-sushis"], + ["J'aime les\n sushis!\t\n", "j-aime-les-sushis"], + ["?header_image", "header-image"], + ["JAime les_sushis", "jaime-les-sushis"], + ["Ébène", "ebene"], + ["ébène", "ebene"], + ["Page1 1", "page1-1"], + ["Page3", "page3"], + ["Page 3", "page-3"], + ["Page 3 3", "page-3-3"], + ["12 Page 3 3", "12-page-3-3"], + ["straßburg", "strassburg"] + ]; + } + + /** + * @dataProvider encodeWithSecretProvider + * @param $input + * @param $secret + */ + public function testEncodeWithSecret($input, $secret) + { + $code = StringHandler::encodeWithSecret($input, $secret); + + // Assert + $this->assertEquals($input, StringHandler::decodeWithSecret($code, $secret)); + } + + /** + * @return array + */ + public function encodeWithSecretProvider() + { + return [ + ["Ligula $* _--Egestas Mattis Nullam", "Commodo Pellentesque Sem Fusce Quam"], + ["Véèsti buœlum Rïsus ", " change#this#secret#very#important"], + ["J'aime les sushis ", " Fringilla Vulputate Dolor Inceptos"], + ["au " . PHP_EOL . "ietaui.\\eauie@auietsrt.trr", "Sit Vestibulum Dolor Ullamcorper Aenean"], + ["JAime les_sushis", "Sit Vestibulum Dolor"], + ]; + } + + /** + * @dataProvider encodeWithSecretNoSaltProvider + * @param $input + * @param $secret + */ + public function testEncodeWithSecretNoSalt($input, $secret) + { + $this->expectException('\\InvalidArgumentException'); + + $code = StringHandler::encodeWithSecret($input, $secret); + + // Assert + $this->assertEquals($input, StringHandler::decodeWithSecret($code, $secret)); + } + + /** + * @return array + */ + public function encodeWithSecretNoSaltProvider() + { + return [ + ["Ligula $* _--Egestas Mattis Nullam", ""], + ["Véèsti buœlum Rïsus ", " "], + ["J'aime les sushis ", " "], + ["auietauieauie@auietsrt.trr", PHP_EOL], + ]; + } +} diff --git a/lib/Models/tests/bootstrap.php b/lib/Models/tests/bootstrap.php new file mode 100644 index 00000000..2e59cdf4 --- /dev/null +++ b/lib/Models/tests/bootstrap.php @@ -0,0 +1,36 @@ +=8.0", + "ext-json": "*", + "lcobucci/jwt": "^4.1", + "roadiz/jwt": "2.1.x-dev", + "roadiz/random": "2.1.x-dev", + "roadiz/models": "2.1.x-dev", + "guzzlehttp/guzzle": "^7.2.0", + "symfony/http-foundation": "5.4.*", + "symfony/security-core": "5.4.*", + "symfony/security-http": "5.4.*", + "symfony/security-csrf": "5.4.*", + "psr/cache": ">=1.0.1", + "symfony/event-dispatcher-contracts": "^1.1.9 || ^2.4.0", + "codercat/jwk-to-pem": "^1.0", + "symfony/routing": "5.4.*" + }, + "require-dev": { + "phpstan/phpstan": "^1.5.3", + "squizlabs/php_codesniffer": "^3.5" + }, + "autoload": { + "psr-4": { + "RZ\\Roadiz\\OpenId\\": "src/" + } + }, + "config": { + "allow-plugins": { + "composer/package-versions-deprecated": true + } + }, + "extra": { + "branch-alias": { + "dev-main": "2.0.x-dev", + "dev-develop": "2.1.x-dev" + } + } +} diff --git a/lib/OpenId/phpcs.xml.dist b/lib/OpenId/phpcs.xml.dist new file mode 100644 index 00000000..da4bfdb5 --- /dev/null +++ b/lib/OpenId/phpcs.xml.dist @@ -0,0 +1,14 @@ + + + + + + + + + + + + ./src + diff --git a/lib/OpenId/phpstan.neon b/lib/OpenId/phpstan.neon new file mode 100644 index 00000000..2359c3d9 --- /dev/null +++ b/lib/OpenId/phpstan.neon @@ -0,0 +1,7 @@ +parameters: + level: max + paths: + - src + reportUnmatchedIgnoredErrors: false + checkGenericClassInNonGenericObjectType: false + checkMissingIterableValueType: false diff --git a/lib/OpenId/src/Authentication/OpenIdAuthenticator.php b/lib/OpenId/src/Authentication/OpenIdAuthenticator.php new file mode 100644 index 00000000..409d6534 --- /dev/null +++ b/lib/OpenId/src/Authentication/OpenIdAuthenticator.php @@ -0,0 +1,251 @@ +httpUtils = $httpUtils; + $this->discovery = $discovery; + $this->client = new Client([ + // You can set any number of default request options. + 'timeout' => 2.0, + ]); + $this->roleStrategy = $roleStrategy; + $this->returnPath = $returnPath; + $this->oauthClientId = $oauthClientId; + $this->oauthClientSecret = $oauthClientSecret; + $this->usernameClaim = $usernameClaim; + $this->targetPathParameter = $targetPathParameter; + $this->defaultRoles = $defaultRoles; + $this->defaultRoute = $defaultRoute; + $this->urlGenerator = $urlGenerator; + $this->jwtConfigurationFactory = $jwtConfigurationFactory; + $this->forceSsl = $forceSsl; + } + + /** + * @inheritDoc + */ + public function supports(Request $request): ?bool + { + return null !== $this->discovery && + $this->httpUtils->checkRequestPath($request, $this->returnPath) && + $request->query->has('state') && + $request->query->has('scope') && + ($request->query->has('code') || $request->query->has('error')); + } + + /** + * @inheritDoc + */ + public function authenticate(Request $request): Passport + { + if ( + null !== $request->query->get('error') && + null !== $request->query->get('error_description') + ) { + throw new AuthenticationException((string) $request->query->get('error_description')); + } + + if (null === $this->discovery) { + throw new DiscoveryNotAvailableException('OpenId discovery service is unavailable, check your configuration.'); + } + + /* + * Verify CSRF token passed to OAuth2 Service provider, + * State is an url_encoded string containing the "token" and other + * optional data + */ + if (null === $request->query->get('state')) { + throw new OpenIdAuthenticationException('State is not valid'); + } + $state = Query::parse((string) $request->query->get('state')); + + /* + * Fetch _target_path parameter from OAuth2 state + */ + if ( + isset($state[$this->targetPathParameter]) + ) { + $request->query->set($this->targetPathParameter, $state[$this->targetPathParameter]); + } + + try { + $tokenEndpoint = $this->discovery->get('token_endpoint'); + $redirectUri = $request->getSchemeAndHttpHost() . $request->getBaseUrl() . $request->getPathInfo(); + + /* + * Redirect URI should always use SSL + */ + if ($this->forceSsl && str_starts_with($redirectUri, 'http://')) { + $redirectUri = str_replace('http://', 'https://', $redirectUri); + } + + if (!\is_string($tokenEndpoint) || empty($tokenEndpoint)) { + throw new OpenIdConfigurationException('Discovery does not provide a valid token_endpoint.'); + } + $response = $this->client->post($tokenEndpoint, [ + 'form_params' => [ + 'code' => $request->query->get('code'), + 'client_id' => $this->oauthClientId ?? '', + 'client_secret' => $this->oauthClientSecret ?? '', + 'redirect_uri' => $redirectUri, + 'grant_type' => 'authorization_code' + ] + ]); + /** @var array $jsonResponse */ + $jsonResponse = \json_decode($response->getBody()->getContents(), true); + } catch (GuzzleException $e) { + throw new OpenIdAuthenticationException( + 'Cannot contact Identity provider to issue authorization_code.' . $e->getMessage(), + $e->getCode(), + $e + ); + } + + if (empty($jsonResponse['id_token'])) { + throw new OpenIdAuthenticationException('JWT is missing from response.'); + } + + $jwt = $this->jwtConfigurationFactory + ->create() + ->parser() + ->parse((string) $jsonResponse['id_token']); + + if (!($jwt instanceof Plain)) { + throw new OpenIdAuthenticationException( + 'JWT token must be instance of ' . Plain::class + ); + } + + if (!$jwt->claims()->has($this->usernameClaim)) { + throw new OpenIdAuthenticationException( + 'JWT does not contain “' . $this->usernameClaim . '” claim.' + ); + } + + $username = $jwt->claims()->get($this->usernameClaim); + if (!\is_string($username) || empty($username)) { + throw new OpenIdAuthenticationException( + 'JWT “' . $this->usernameClaim . '” claim is not valid.' + ); + } + $passport = new Passport( + new UserBadge($username, function () use ($jwt, $username) { + $roles = $this->defaultRoles; + if ($this->roleStrategy->supports()) { + $roles = array_merge($roles, $this->roleStrategy->getRoles() ?? []); + } + return new OpenIdAccount( + $username, + array_unique($roles), + $jwt + ); + }), + new CustomCredentials( + function (Plain $jwt) { + $configuration = $this->jwtConfigurationFactory->create(); + $constraints = $configuration->validationConstraints(); + try { + $configuration->validator()->assert($jwt, ...$constraints); + } catch (RequiredConstraintsViolated $e) { + throw new OpenIdAuthenticationException($e->getMessage(), 0, $e); + } + return true; + }, + $jwt + ) + ); + + $passport->setAttribute('jwt', $jwt); + $passport->setAttribute('token', !empty($jsonResponse['access_token']) ? $jsonResponse['access_token'] : $jwt->toString()); + + return $passport; + } + + /** + * @inheritDoc + */ + public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response + { + if ($targetPath = $this->getTargetPath($request->getSession(), $firewallName)) { + return new RedirectResponse($targetPath); + } + + return new RedirectResponse($this->urlGenerator->generate($this->defaultRoute)); + } + + /** + * @inheritDoc + */ + public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response + { + if ($request->hasSession()) { + $request->getSession()->set(Security::AUTHENTICATION_ERROR, $exception); + } + $url = $this->urlGenerator->generate($this->defaultRoute); + + return new RedirectResponse($url); + } +} diff --git a/lib/OpenId/src/Authentication/Provider/ChainJwtRoleStrategy.php b/lib/OpenId/src/Authentication/Provider/ChainJwtRoleStrategy.php new file mode 100644 index 00000000..2e2ae2ad --- /dev/null +++ b/lib/OpenId/src/Authentication/Provider/ChainJwtRoleStrategy.php @@ -0,0 +1,49 @@ + + */ + private array $strategies = []; + + /** + * @param array $strategies + */ + public function __construct(array $strategies) + { + $this->strategies = $strategies; + foreach ($this->strategies as $strategy) { + if (!($strategy instanceof JwtRoleStrategy)) { + throw new \InvalidArgumentException('Strategy must implement ' . JwtRoleStrategy::class); + } + } + } + + public function supports(): bool + { + /** @var JwtRoleStrategy $strategy */ + foreach ($this->strategies as $strategy) { + if ($strategy->supports()) { + return true; + } + } + return false; + } + + public function getRoles(): ?array + { + $roles = []; + /** @var JwtRoleStrategy $strategy */ + foreach ($this->strategies as $strategy) { + if ($strategy->supports()) { + $roles = array_merge($roles, $strategy->getRoles() ?? []); + } + } + return !empty($roles) ? array_unique($roles) : null; + } +} diff --git a/lib/OpenId/src/Authentication/Provider/JwtRoleStrategy.php b/lib/OpenId/src/Authentication/Provider/JwtRoleStrategy.php new file mode 100644 index 00000000..a65102f1 --- /dev/null +++ b/lib/OpenId/src/Authentication/Provider/JwtRoleStrategy.php @@ -0,0 +1,11 @@ +getJwtToken()->isExpired(new \DateTime('now'))) { + throw new UserNotFoundException('OpenId token has expired, please authenticate again…'); + } + return $user; + } + + throw new UnsupportedUserException(); + } + + /** + * @inheritDoc + * @param class-string $class + */ + public function supportsClass(string $class): bool + { + return $class === OpenIdAccount::class; + } +} diff --git a/lib/OpenId/src/Authentication/Provider/SettingsRoleStrategy.php b/lib/OpenId/src/Authentication/Provider/SettingsRoleStrategy.php new file mode 100644 index 00000000..6ad900ae --- /dev/null +++ b/lib/OpenId/src/Authentication/Provider/SettingsRoleStrategy.php @@ -0,0 +1,38 @@ +settingsBag = $settingsBag; + } + + public function supports(): bool + { + return !empty($this->settingsBag->get(static::SETTING_NAME)); + } + + public function getRoles(): ?array + { + $settings = $this->settingsBag->get(static::SETTING_NAME); + if (!is_string($settings)) { + return null; + } + return array_map(function ($role) { + return trim($role); + }, explode(',', $settings)); + } +} diff --git a/lib/OpenId/src/Discovery.php b/lib/OpenId/src/Discovery.php new file mode 100644 index 00000000..1f79ee16 --- /dev/null +++ b/lib/OpenId/src/Discovery.php @@ -0,0 +1,130 @@ +discoveryUri = $discoveryUri; + $this->cacheAdapter = $cacheAdapter; + } + + protected function populateParameters(): void + { + $cacheItem = $this->cacheAdapter->getItem(static::CACHE_KEY); + if ($cacheItem->isHit()) { + /** @var array $parameters */ + $parameters = $cacheItem->get(); + } else { + try { + $client = new Client([ + // You can set any number of default request options. + 'timeout' => 2.0, + ]); + $response = $client->get($this->discoveryUri); + /** @var array $parameters */ + $parameters = \json_decode($response->getBody()->getContents(), true); + $cacheItem->set($parameters); + $this->cacheAdapter->save($cacheItem); + } catch (RequestException $exception) { + return; + } + } + + $this->parameters = []; + foreach ($parameters as $key => $parameter) { + $this->parameters[$key] = $parameter; + } + $this->ready = true; + } + + /** + * @return bool + */ + public function canVerifySignature(): bool + { + return $this->has('jwks_uri'); + } + + /** + * @return array|null + * @throws Base64DecodeException + * @throws JWKConverterException + * @throws GuzzleException + * @see https://auth0.com/docs/tokens/json-web-tokens/json-web-key-sets + */ + public function getPems(): ?array + { + $jwksData = $this->getJwksData(); + if (null !== $jwksData && isset($jwksData['keys'])) { + $converter = new JWKConverter(); + return $converter->multipleToPEM($jwksData['keys']); + } + return null; + } + + /** + * @return array|null + * @throws GuzzleException + */ + protected function getJwksData(): ?array + { + if (null === $this->jwksData && $this->has('jwks_uri')) { + $jwksUri = $this->get('jwks_uri'); + if (!is_string($jwksUri) || empty($jwksUri)) { + return null; + } + $cacheItem = $this->cacheAdapter->getItem('jwks_uri_' . \md5($jwksUri)); + if ($cacheItem->isHit()) { + $data = $cacheItem->get(); + if (is_array($data)) { + $this->jwksData = $data; + } else { + $this->jwksData = null; + } + } else { + $client = new Client([ + // You can set any number of default request options. + 'timeout' => 3.0, + ]); + $response = $client->get($jwksUri); + $data = \json_decode($response->getBody()->getContents(), true); + if (is_array($data)) { + $this->jwksData = $data; + } else { + $this->jwksData = null; + } + $cacheItem->set($this->jwksData)->expiresAfter(3600); + $this->cacheAdapter->save($cacheItem); + } + } + return $this->jwksData; + } +} diff --git a/lib/OpenId/src/Exception/DiscoveryNotAvailableException.php b/lib/OpenId/src/Exception/DiscoveryNotAvailableException.php new file mode 100644 index 00000000..db0f6bdd --- /dev/null +++ b/lib/OpenId/src/Exception/DiscoveryNotAvailableException.php @@ -0,0 +1,20 @@ +discovery = $discovery; + $this->csrfTokenManager = $csrfTokenManager; + $this->openIdHostedDomain = $openIdHostedDomain; + $this->oauthClientId = $oauthClientId; + $this->openIdScopes = array_filter($openIdScopes ?? []); + } + + /** + * @param Request $request + * + * @return bool + */ + public function isSupported(Request $request): bool + { + return null !== $this->discovery; + } + + public function generate( + Request $request, + string $redirectUri, + array $state = [], + string $responseType = 'code', + bool $forceSsl = true + ): string { + if (null === $this->discovery) { + throw new DiscoveryNotAvailableException( + 'OpenID discovery is not well configured' + ); + } + /** @var array $supportedResponseTypes */ + $supportedResponseTypes = $this->discovery->get('response_types_supported', []); + if (!in_array($responseType, $supportedResponseTypes)) { + throw new DiscoveryNotAvailableException( + 'OpenID response_type is not supported by your identity provider' + ); + } + + /* + * Redirect URI should always use SSL + */ + if ($forceSsl && str_starts_with($redirectUri, 'http://')) { + $redirectUri = str_replace('http://', 'https://', $redirectUri); + } + + /** @var array $supportedScopes */ + $supportedScopes = $this->discovery->get('scopes_supported'); + + if (count($this->openIdScopes) > 0 && !empty($this->openIdScopes)) { + $customScopes = array_intersect( + $this->openIdScopes, + $supportedScopes + ); + } else { + $customScopes = $supportedScopes; + } + $stateToken = $this->csrfTokenManager->getToken(static::OAUTH_STATE_TOKEN); + return $this->discovery->get('authorization_endpoint') . '?' . http_build_query([ + 'response_type' => 'code', + 'hd' => $this->openIdHostedDomain, + 'state' => http_build_query(array_merge($state, [ + 'token' => $stateToken->getValue() + ])), + 'nonce' => (new TokenGenerator())->generateToken(), + 'login_hint' => $request->get('email', null), + 'scope' => implode(' ', $customScopes), + 'client_id' => $this->oauthClientId, + 'redirect_uri' => $redirectUri, + ]); + } +} diff --git a/lib/OpenId/src/OpenIdJwtConfigurationFactory.php b/lib/OpenId/src/OpenIdJwtConfigurationFactory.php new file mode 100644 index 00000000..bb91cdb6 --- /dev/null +++ b/lib/OpenId/src/OpenIdJwtConfigurationFactory.php @@ -0,0 +1,100 @@ +discovery = $discovery; + $this->verifyUserInfo = $verifyUserInfo; + $this->openIdHostedDomain = $openIdHostedDomain; + $this->oauthClientId = $oauthClientId; + } + + /** + * @return Constraint[] + */ + protected function getValidationConstraints(): array + { + $validators = [ + new LooseValidAt(SystemClock::fromSystemTimezone()), + ]; + + if (!empty($this->oauthClientId)) { + $validators[] = new PermittedFor(trim($this->oauthClientId)); + } + + if (!empty($this->openIdHostedDomain)) { + $validators[] = new HostedDomain(trim($this->openIdHostedDomain)); + } + + if (null !== $this->discovery) { + $issuer = $this->discovery->get('issuer'); + $userinfoEndpoint = $this->discovery->get('userinfo_endpoint'); + if (is_string($issuer) && !empty($issuer)) { + $validators[] = new IssuedBy($issuer); + } + if ($this->verifyUserInfo && is_string($userinfoEndpoint) && !empty($userinfoEndpoint)) { + $validators[] = new UserInfoEndpoint(trim($userinfoEndpoint)); + } + } + + return $validators; + } + + public function create(): Configuration + { + $configuration = Configuration::forUnsecuredSigner(); + /* + * Verify JWT signature if asymmetric crypto is used and if PHP gmp extension is loaded. + */ + if ( + null !== $this->discovery && + $this->discovery->canVerifySignature() && + null !== $pems = $this->discovery->getPems() + ) { + /** @var array $signingAlgValuesSupported */ + $signingAlgValuesSupported = $this->discovery->get('id_token_signing_alg_values_supported', []); + if ( + in_array( + 'RS256', + $signingAlgValuesSupported + ) && + !empty($pems[0]) + ) { + $configuration = Configuration::forAsymmetricSigner( + new Sha256(), + InMemory::plainText($pems[0]), + InMemory::plainText($pems[0]) + ); + } + } + + $configuration->setValidationConstraints(...$this->getValidationConstraints()); + return $configuration; + } +} diff --git a/lib/OpenId/src/User/OpenIdAccount.php b/lib/OpenId/src/User/OpenIdAccount.php new file mode 100644 index 00000000..94c3ea2a --- /dev/null +++ b/lib/OpenId/src/User/OpenIdAccount.php @@ -0,0 +1,309 @@ + + * @SymfonySerializer\Groups({"user"}) + */ + protected array $roles; + /** + * @var string|null + * @SymfonySerializer\Groups({"user"}) + */ + protected ?string $issuer = null; + /** + * @var string + * @SymfonySerializer\Groups({"user"}) + */ + protected string $email; + /** + * @var string|null + * @SymfonySerializer\Groups({"user"}) + */ + protected ?string $name = null; + /** + * @var string|null + * @SymfonySerializer\Groups({"user"}) + */ + protected ?string $nickname = null; + /** + * @var string|null + * @SymfonySerializer\Groups({"user"}) + */ + protected ?string $website = null; + /** + * @var string|null + * @SymfonySerializer\Groups({"user"}) + */ + protected ?string $locale = null; + /** + * @var string|null + * @SymfonySerializer\Groups({"user"}) + */ + protected ?string $phoneNumber = null; + /** + * @var array|null + * @SymfonySerializer\Groups({"user"}) + */ + protected ?array $address = null; + /** + * @var string|null + * @SymfonySerializer\Groups({"user"}) + */ + protected ?string $familyName = null; + /** + * @var string|null + * @SymfonySerializer\Groups({"user"}) + */ + protected ?string $middleName = null; + /** + * @var string|null + * @SymfonySerializer\Groups({"user"}) + */ + protected ?string $givenName = null; + /** + * @var string|null + * @SymfonySerializer\Groups({"user"}) + */ + protected ?string $picture = null; + /** + * @var string|null + * @SymfonySerializer\Groups({"user"}) + */ + protected ?string $profile = null; + /** + * @var Token + */ + protected Token $jwtToken; + + /** + * @param string $email + * @param array $roles + * @param Token $jwtToken + */ + public function __construct( + string $email, + array $roles, + Token $jwtToken + ) { + $this->roles = $roles; + $this->email = $email; + $this->jwtToken = $jwtToken; + if (!($jwtToken instanceof Token\Plain)) { + throw new \InvalidArgumentException('Token must be an instance of ' . Token\Plain::class); + } + /* + * https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims + */ + $claims = $jwtToken->claims(); + $this->name = $this->getStringClaim($claims, 'name'); + $this->issuer = $this->getStringClaim($claims, 'iss'); + $this->givenName = $this->getStringClaim($claims, 'given_name'); + $this->familyName = $this->getStringClaim($claims, 'family_name'); + $this->middleName = $this->getStringClaim($claims, 'middle_name'); + $this->nickname = $this->getStringClaim($claims, 'nickname'); + $this->profile = $this->getStringClaim($claims, 'profile'); + $this->picture = $this->getStringClaim($claims, 'picture'); + $this->locale = $this->getStringClaim($claims, 'locale'); + $this->phoneNumber = $this->getStringClaim($claims, 'phone_number'); + $this->address = $this->getArrayClaim($claims, 'address'); + } + + private function getStringClaim(Token\DataSet $claims, string $claimName): ?string + { + if ($claims->has($claimName) && is_string($claims->get($claimName))) { + return $claims->get($claimName); + } + return null; + } + private function getArrayClaim(Token\DataSet $claims, string $claimName): ?array + { + if ($claims->has($claimName) && is_array($claims->get($claimName))) { + return $claims->get($claimName); + } + return null; + } + + /** + * @inheritDoc + */ + public function getRoles(): array + { + return $this->roles; + } + + public function getPassword(): string + { + return ''; + } + + public function getSalt(): string + { + return ''; + } + + public function getUsername(): string + { + return $this->email ?? ''; + } + + /** + * @inheritDoc + * @return void + */ + public function eraseCredentials() + { + return; + } + + /** + * @return string + */ + public function getEmail(): ?string + { + return $this->email; + } + + /** + * @return string + */ + public function getName(): ?string + { + return $this->name; + } + + /** + * @return string + */ + public function getFamilyName(): ?string + { + return $this->familyName; + } + + /** + * @return string + */ + public function getGivenName(): ?string + { + return $this->givenName; + } + + /** + * @return string + */ + public function getPicture(): ?string + { + return $this->picture; + } + + /** + * @return string|null + */ + public function getNickname(): ?string + { + return $this->nickname; + } + + /** + * @return string|null + */ + public function getWebsite(): ?string + { + return $this->website; + } + + /** + * @return string|null + */ + public function getLocale(): ?string + { + return $this->locale; + } + + /** + * @return string|null + */ + public function getPhoneNumber(): ?string + { + return $this->phoneNumber; + } + + /** + * @return array|null + */ + public function getAddress(): ?array + { + return $this->address; + } + + /** + * @return string|null + */ + public function getMiddleName(): ?string + { + return $this->middleName; + } + + /** + * @return string|null + */ + public function getProfile(): ?string + { + return $this->profile; + } + + /** + * @return Token + */ + public function getJwtToken(): Token + { + return $this->jwtToken; + } + + /** + * @return string|null + */ + public function getIssuer(): ?string + { + return $this->issuer; + } + + public function getUserIdentifier(): string + { + return $this->getEmail() ?? ''; + } + + public function isEqualTo(UserInterface $user): bool + { + if (!$user instanceof OpenIdAccount) { + return false; + } + + if ($this->getEmail() !== $user->getEmail()) { + return false; + } + + if (array_diff($this->getRoles(), $user->getRoles())) { + return false; + } + + if ($this->getJwtToken() !== $user->getJwtToken()) { + return false; + } + + return true; + } +} diff --git a/lib/Random b/lib/Random deleted file mode 160000 index 3444ef11..00000000 --- a/lib/Random +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 3444ef1174c02f7e5f63a955f0397d3b7648dbb6 diff --git a/lib/Random/.github/workflows/run-test.yml b/lib/Random/.github/workflows/run-test.yml new file mode 100644 index 00000000..0b3245e5 --- /dev/null +++ b/lib/Random/.github/workflows/run-test.yml @@ -0,0 +1,41 @@ +name: Unit tests, static analysis and code style + +on: + push: + branches: + - develop + - 'release/**' + - 'hotfix/**' + tags: ['**'] + pull_request: + types: + - opened + - reopened + - synchronize + - ready_for_review + +jobs: + run-tests: + runs-on: ubuntu-latest + strategy: + matrix: + php-version: ['8.0', '8.1'] + steps: + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + - uses: actions/checkout@v3 + - name: Cache Composer packages + id: composer-cache + uses: actions/cache@v3 + with: + path: vendor + key: ${{ runner.os }}-php-${{ matrix.php-version }}-${{ hashFiles('**/composer.lock') }} + restore-keys: | + ${{ runner.os }}-php-${{ matrix.php-version }}- + - name: Install Dependencies + run: composer install --no-scripts --no-ansi --no-interaction --no-progress + - name: Run PHP Code Sniffer + run: vendor/bin/phpcs --extensions=php --warning-severity=0 --standard=PSR12 -p ./src + - name: Run PHPStan + run: vendor/bin/phpstan analyse --no-progress -c phpstan.neon diff --git a/lib/Random/.gitignore b/lib/Random/.gitignore new file mode 100644 index 00000000..bdab0cbc --- /dev/null +++ b/lib/Random/.gitignore @@ -0,0 +1,55 @@ +# Cache and logs (Symfony2) +/app/cache/* +/app/logs/* +!app/cache/.gitkeep +!app/logs/.gitkeep + +# Email spool folder +/app/spool/* + +# Cache, session files and logs (Symfony3) +/var/cache/* +/var/logs/* +/var/sessions/* +!var/cache/.gitkeep +!var/logs/.gitkeep +!var/sessions/.gitkeep + +# Logs (Symfony4) +/var/log/* +!var/log/.gitkeep + +# Parameters +/app/config/parameters.yml +/app/config/parameters.ini + +# Managed by Composer +/app/bootstrap.php.cache +/var/bootstrap.php.cache +/bin/* +!bin/console +!bin/symfony_requirements +/vendor/ + +# Assets and user uploads +/web/bundles/ +/web/uploads/ + +# PHPUnit +/app/phpunit.xml +/phpunit.xml +/.phpcs-cache + +# Build data +/build/ + +# Composer PHAR +/composer.phar + +# Backup entities generated with doctrine:generate:entities command +**/Entity/*~ + +# Embedded web-server pid file +/.web-server-pid +/composer.lock +/report.txt diff --git a/lib/Random/.travis.yml b/lib/Random/.travis.yml new file mode 100644 index 00000000..0bef6810 --- /dev/null +++ b/lib/Random/.travis.yml @@ -0,0 +1,16 @@ +language: php +sudo: required +php: + - 7.4 + - 8.0 + - 8.1 + - nightly +install: + - curl -s http://getcomposer.org/installer | php + - php composer.phar install --dev --no-interaction +script: + - vendor/bin/phpcs --report=full --report-file=./report.txt --extensions=php --warning-severity=0 --standard=PSR2 -p ./src + - vendor/bin/phpstan analyse -c phpstan.neon +jobs: + allow_failures: + - php: nightly diff --git a/lib/Random/LICENSE b/lib/Random/LICENSE new file mode 100644 index 00000000..9549c142 --- /dev/null +++ b/lib/Random/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Roadiz + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/lib/Random/Makefile b/lib/Random/Makefile new file mode 100644 index 00000000..5f4d6d24 --- /dev/null +++ b/lib/Random/Makefile @@ -0,0 +1,3 @@ +test: + vendor/bin/phpcs --report=full --report-file=./report.txt -p ./src + vendor/bin/phpstan analyse -c phpstan.neon diff --git a/lib/Random/README.md b/lib/Random/README.md new file mode 100644 index 00000000..3831d99b --- /dev/null +++ b/lib/Random/README.md @@ -0,0 +1,4 @@ +# random +Roadiz sub-package for secret and random generators + +[![Unit tests, static analysis and code style](https://github.com/roadiz/random/actions/workflows/run-test.yml/badge.svg?branch=develop)](https://github.com/roadiz/random/actions/workflows/run-test.yml) diff --git a/lib/Random/composer.json b/lib/Random/composer.json new file mode 100644 index 00000000..914de6b6 --- /dev/null +++ b/lib/Random/composer.json @@ -0,0 +1,33 @@ +{ + "name": "roadiz/random", + "description": "Roadiz sub-package for secret and random generators", + "type": "library", + "license": "MIT", + "authors": [ + { + "name": "Ambroise Maupate", + "email": "ambroise@roadiz.io", + "homepage": "https://www.roadiz.io", + "role": "Lead developer" + } + ], + "require": { + "php": ">=8.0", + "psr/log": ">=1.1" + }, + "require-dev": { + "phpstan/phpstan": "^1.5.3", + "squizlabs/php_codesniffer": "^3.5" + }, + "autoload": { + "psr-4": { + "RZ\\Roadiz\\Random\\": "src/" + } + }, + "extra": { + "branch-alias": { + "dev-main": "2.0.x-dev", + "dev-develop": "2.1.x-dev" + } + } +} diff --git a/lib/Random/phpcs.xml.dist b/lib/Random/phpcs.xml.dist new file mode 100644 index 00000000..da4bfdb5 --- /dev/null +++ b/lib/Random/phpcs.xml.dist @@ -0,0 +1,14 @@ + + + + + + + + + + + + ./src + diff --git a/lib/Random/phpstan.neon b/lib/Random/phpstan.neon new file mode 100644 index 00000000..855a75d9 --- /dev/null +++ b/lib/Random/phpstan.neon @@ -0,0 +1,11 @@ +parameters: + level: max + paths: + - src + excludePaths: + - */node_modules/* + - */bower_components/* + - */static/* + reportUnmatchedIgnoredErrors: false + checkGenericClassInNonGenericObjectType: false + checkMissingIterableValueType: false diff --git a/lib/Random/src/PasswordGenerator.php b/lib/Random/src/PasswordGenerator.php new file mode 100644 index 00000000..942bf387 --- /dev/null +++ b/lib/Random/src/PasswordGenerator.php @@ -0,0 +1,46 @@ +logger = $logger; + // determine whether to use OpenSSL + if (defined('PHP_WINDOWS_VERSION_BUILD') && version_compare(PHP_VERSION, '5.3.4', '<')) { + $this->useOpenSsl = false; + } elseif (!function_exists('openssl_random_pseudo_bytes')) { + if (null !== $this->logger) { + $this->logger->notice('It is recommended that you enable the "openssl" extension for random number generation.'); + } + $this->useOpenSsl = false; + } else { + $this->useOpenSsl = true; + } + } + + /** + * @param int $nbBytes + * @return string + */ + protected function getRandomNumber(int $nbBytes = 32): string + { + // try OpenSSL + if ($this->useOpenSsl) { + $bytes = openssl_random_pseudo_bytes($nbBytes, $strong); + + if (false !== $bytes && true === $strong) { + return $bytes; + } + + if (null !== $this->logger) { + $this->logger->info('OpenSSL did not produce a secure random number.'); + } + } + + return hash('sha256', uniqid((string) mt_rand(), true), true); + } +} diff --git a/lib/Random/src/SaltGenerator.php b/lib/Random/src/SaltGenerator.php new file mode 100644 index 00000000..55dd79db --- /dev/null +++ b/lib/Random/src/SaltGenerator.php @@ -0,0 +1,16 @@ +getRandomNumber(24)), '{}', '-_'); + } +} diff --git a/lib/Random/src/SaltGeneratorInterface.php b/lib/Random/src/SaltGeneratorInterface.php new file mode 100644 index 00000000..52f3b973 --- /dev/null +++ b/lib/Random/src/SaltGeneratorInterface.php @@ -0,0 +1,13 @@ +getRandomNumber()), '+/', '-_'), '='); + } +} diff --git a/lib/Random/src/TokenGeneratorInterface.php b/lib/Random/src/TokenGeneratorInterface.php new file mode 100644 index 00000000..6e9c53ff --- /dev/null +++ b/lib/Random/src/TokenGeneratorInterface.php @@ -0,0 +1,13 @@ + symfony/framework-bundle ### +/.env.local +/.env.local.php +/.env.*.local +/config/secrets/prod/prod.decrypt.private.php +/public/bundles/ +/var/ +/vendor/ +###< symfony/framework-bundle ### + +###> symfony/phpunit-bridge ### +.phpunit.result.cache +/phpunit.xml +###< symfony/phpunit-bridge ### + +###> phpunit/phpunit ### +/phpunit.xml +.phpunit.result.cache +###< phpunit/phpunit ### +/lib/ +/.data/ + +###> squizlabs/php_codesniffer ### +/.phpcs-cache +/phpcs.xml +###< squizlabs/php_codesniffer ### +/report.txt +/composer.lock diff --git a/lib/RoadizCompatBundle/.travis.yml b/lib/RoadizCompatBundle/.travis.yml new file mode 100644 index 00000000..660b9c6f --- /dev/null +++ b/lib/RoadizCompatBundle/.travis.yml @@ -0,0 +1,16 @@ +language: php +sudo: required +php: + - 7.4 + - 8.0 + - 8.1 + - nightly +install: + - curl -s http://getcomposer.org/installer | php + - php composer.phar install --dev --no-interaction +script: + - vendor/bin/phpcs --report=full --report-file=./report.txt -p ./src + - vendor/bin/phpstan analyse -c phpstan.neon +jobs: + allow_failures: + - php: nightly diff --git a/lib/RoadizCompatBundle/CHANGELOG.md b/lib/RoadizCompatBundle/CHANGELOG.md new file mode 100644 index 00000000..c3d9be24 --- /dev/null +++ b/lib/RoadizCompatBundle/CHANGELOG.md @@ -0,0 +1,30 @@ +## 2.0.3 (2022-11-09) + +### Bug Fixes + +* Do not allow entityListManager to sort and search upon request on frontend ([1e1601c](https://github.com/roadiz/compat-bundle/commit/1e1601c22f845a772cde7f455a051bc2a421c2f6)) + +## 2.0.2 (2022-08-05) + +### Bug Fixes + +* Do not execute themes:migrate command on all environments ([6e535d9](https://github.com/roadiz/compat-bundle/commit/6e535d9f583d3699b42a18a85ad5069025795180)) + +## 2.0.1 (2022-07-01) + +### Bug Fixes + +* Missing subscribed services in Controller ([e10a628](https://github.com/roadiz/compat-bundle/commit/e10a6289f0335d7b3f0e1734daa9732f462693da)) + +## 2.0.0 (2022-07-01) + +### Features + +* Added aliases for UserJoinedGroupEvent and UserLeavedGroupEvent ([b19d7a5](https://github.com/roadiz/compat-bundle/commit/b19d7a508de40f46397ea8d347526516405d9838)) + +### Bug Fixes + +* Missing alias for RZ\Roadiz\CoreBundle\Exception\EntityAlreadyExistsException ([5b2e105](https://github.com/roadiz/compat-bundle/commit/5b2e105e8cf6f5e7c816c7e75b7acf628d2f782f)) +* Theme migrate command ([745362e](https://github.com/roadiz/compat-bundle/commit/745362e4951348b305dd2b269f006cacc1ef92f6)) +* Use default exception behaviour for JSON and LD+JSON formats ([afc45e9](https://github.com/roadiz/compat-bundle/commit/afc45e9cdad2e6b01cf03f42167287608f089241)) + diff --git a/lib/RoadizCompatBundle/LICENSE.md b/lib/RoadizCompatBundle/LICENSE.md new file mode 100644 index 00000000..d4d8a009 --- /dev/null +++ b/lib/RoadizCompatBundle/LICENSE.md @@ -0,0 +1,9 @@ +The MIT License (MIT) + +Copyright © 2023 Ambroise Maupate + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/lib/RoadizCompatBundle/Makefile b/lib/RoadizCompatBundle/Makefile new file mode 100644 index 00000000..16ef8148 --- /dev/null +++ b/lib/RoadizCompatBundle/Makefile @@ -0,0 +1,3 @@ +test: + php -d "memory_limit=-1" vendor/bin/phpcbf --report=full --report-file=./report.txt -p ./src + php -d "memory_limit=-1" vendor/bin/phpstan analyse -c phpstan.neon diff --git a/lib/RoadizCompatBundle/README.md b/lib/RoadizCompatBundle/README.md new file mode 100644 index 00000000..9870f123 --- /dev/null +++ b/lib/RoadizCompatBundle/README.md @@ -0,0 +1,46 @@ +# Roadiz compatibility bundle +**Adds a thin compatibility layer between Roadiz v2 and legacy themes** + +![Run test status](https://github.com/roadiz/compat-bundle/actions/workflows/run-test.yml/badge.svg?branch=develop) + +Installation +============ + +Make sure Composer is installed globally, as explained in the +[installation chapter](https://getcomposer.org/doc/00-intro.md) +of the Composer documentation. + +Applications that use Symfony Flex +---------------------------------- + +Open a command console, enter your project directory and execute: + +```console +$ composer require roadiz/compat-bundle +``` + +Applications that don't use Symfony Flex +---------------------------------------- + +### Step 1: Download the Bundle + +Open a command console, enter your project directory and execute the +following command to download the latest stable version of this bundle: + +```console +$ composer require roadiz/compat-bundle +``` + +### Step 2: Enable the Bundle + +Then, enable the bundle by adding it to the list of registered bundles +in the `config/bundles.php` file of your project: + +```php +// config/bundles.php + +return [ + // ... + \RZ\Roadiz\CompatBundle\RoadizCompatBundle::class => ['all' => true], +]; +``` diff --git a/lib/RoadizCompatBundle/composer.json b/lib/RoadizCompatBundle/composer.json new file mode 100644 index 00000000..46328942 --- /dev/null +++ b/lib/RoadizCompatBundle/composer.json @@ -0,0 +1,66 @@ +{ + "name": "roadiz/compat-bundle", + "license": "MIT", + "keywords": [ + "cms", + "backoffice", + "rezo zero" + ], + "authors": [ + { + "name": "Ambroise Maupate", + "email": "ambroise@roadiz.io", + "homepage": "https://www.roadiz.io", + "role": "Lead developer" + } + ], + "type": "symfony-bundle", + "minimum-stability": "stable", + "prefer-stable": true, + "require": { + "php": ">=8.0", + "symfony/framework-bundle": "5.4.*", + "roadiz/core-bundle": "2.1.x-dev", + "roadiz/openid": "2.1.x-dev", + "pimple/pimple": "^3.3.1" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.4", + "phpstan/phpstan": "^1.5.3", + "phpstan/phpstan-symfony": "^1.1.8", + "phpstan/phpstan-doctrine": "^1.3", + "squizlabs/php_codesniffer": "^3.5", + "roadiz/models": "2.1.x-dev", + "roadiz/jwt": "2.1.x-dev", + "roadiz/random": "2.1.x-dev", + "roadiz/documents": "2.1.x-dev", + "roadiz/markdown": "2.1.x-dev", + "roadiz/entity-generator": "2.1.x-dev" + }, + "config": { + "optimize-autoloader": true, + "preferred-install": { + "*": "dist" + }, + "sort-packages": true, + "allow-plugins": { + "symfony/flex": false, + "symfony/runtime": false, + "php-http/discovery": false + } + }, + "autoload": { + "psr-4": { + "RZ\\Roadiz\\CompatBundle\\": "src/" + }, + "files": [ + "deprecated.php" + ] + }, + "extra": { + "branch-alias": { + "dev-main": "2.0.x-dev", + "dev-develop": "2.1.x-dev" + } + } +} diff --git a/lib/RoadizCompatBundle/config/packages/roadiz_compat.yaml b/lib/RoadizCompatBundle/config/packages/roadiz_compat.yaml new file mode 100644 index 00000000..8d1d87d7 --- /dev/null +++ b/lib/RoadizCompatBundle/config/packages/roadiz_compat.yaml @@ -0,0 +1,5 @@ +roadiz_compat: + themes: + - classname: \Themes\DefaultTheme\DefaultThemeApp + + diff --git a/lib/RoadizCompatBundle/config/services.yaml b/lib/RoadizCompatBundle/config/services.yaml new file mode 100644 index 00000000..eb194f06 --- /dev/null +++ b/lib/RoadizCompatBundle/config/services.yaml @@ -0,0 +1,124 @@ +--- +services: + # default configuration for services in *this* file + _defaults: + autowire: true # Automatically injects dependencies in your services. + autoconfigure: true # Automatically registers your services as commands, event subscribers, etc. + bind: + $publicDir: '%kernel.project_dir%/public' + $cacheDir: '%kernel.project_dir%/var/cache' + $projectDir: '%kernel.project_dir%' + $debug: '%kernel.debug%' + + RZ\Roadiz\CompatBundle\: + resource: '../src/' + exclude: + - '../src/DependencyInjection/' + - '../src/Entity/' + - '../src/Kernel.php' + - '../src/Tests/' + - '../src/Event/' + + # + # Automatic themes registration + # + Themes\: + resource: '%kernel.project_dir%/themes/' + autowire: true + autoconfigure: true + exclude: + - '%kernel.project_dir%/themes/DependencyInjection/' + - '%kernel.project_dir%/themes/app/' + - '%kernel.project_dir%/themes/public/' + - '%kernel.project_dir%/themes/Resources/' + - '%kernel.project_dir%/themes/Services/' + - '%kernel.project_dir%/themes/static/' + - '%kernel.project_dir%/themes/Entity/' + - '%kernel.project_dir%/themes/Kernel.php' + - '%kernel.project_dir%/themes/Tests/' + + # Explicit declaration + RZ\Roadiz\CompatBundle\Controller\AppController: ~ + RZ\Roadiz\CompatBundle\Controller\Controller: ~ + RZ\Roadiz\CompatBundle\Controller\FrontendController: ~ + + securityTokenStorage: + alias: security.token_storage + public: true + factory.handler: + alias: RZ\Roadiz\CoreBundle\EntityHandler\HandlerFactory + public: true + settingsBag: + alias: RZ\Roadiz\CoreBundle\Bag\Settings + public: true + nodeTypesBag: + alias: RZ\Roadiz\CoreBundle\Bag\NodeTypes + public: true + rolesBag: + alias: RZ\Roadiz\CoreBundle\Bag\Roles + public: true + assetPackages: + alias: RZ\Roadiz\Documents\Packages + deprecated: ~ + public: true + Symfony\Contracts\Translation\TranslatorInterface: + alias: 'translator.default' + public: true + formFactory: + alias: 'form.factory' + public: true + csrfTokenManager: + alias: 'security.csrf.token_manager' + public: true + dispatcher: + alias: 'event_dispatcher' + public: true + logger: + alias: 'monolog.logger' + public: true + Symfony\Component\HttpFoundation\ParameterBag: + alias: RZ\Roadiz\CoreBundle\Bag\Settings + securityAuthenticationUtils: + alias: Symfony\Component\Security\Http\Authentication\AuthenticationUtils + public: true + entityManager: + public: true + alias: 'doctrine.orm.default_entity_manager' + em: + public: true + alias: 'doctrine.orm.default_entity_manager' + + # + # Themes aware stuff + # + roadiz_compat.twig_loader: + class: Twig\Loader\FilesystemLoader + tags: ['twig.loader'] + RZ\Roadiz\CompatBundle\Routing\ThemeRoutesLoader: + tags: [ routing.loader ] + + # + # Made routers theme aware + # + RZ\Roadiz\CompatBundle\Routing\ThemeAwareNodeUrlMatcher: + decorates: RZ\Roadiz\CoreBundle\Routing\NodeUrlMatcher + arguments: + - '@RZ\Roadiz\CompatBundle\Theme\ThemeResolverInterface' + - '@.inner' + RZ\Roadiz\CompatBundle\Routing\ThemeAwareNodeRouter: + decorates: RZ\Roadiz\CoreBundle\Routing\NodeRouter + arguments: + - '@RZ\Roadiz\CompatBundle\Theme\ThemeResolverInterface' + - '@.inner' + + RZ\Roadiz\CompatBundle\EventSubscriber\MaintenanceModeSubscriber: + arguments: + - '@RZ\Roadiz\CoreBundle\Bag\Settings' + - '@security.helper' + - '@RZ\Roadiz\CompatBundle\Theme\ThemeResolverInterface' + - '@service_container' + RZ\Roadiz\CompatBundle\EventSubscriber\ExceptionSubscriber: + arguments: + - '@RZ\Roadiz\CompatBundle\Theme\ThemeResolverInterface' + - '@RZ\Roadiz\CoreBundle\Exception\ExceptionViewer' + - '@service_container' diff --git a/lib/RoadizCompatBundle/deprecated.php b/lib/RoadizCompatBundle/deprecated.php new file mode 100644 index 00000000..a5e6be9b --- /dev/null +++ b/lib/RoadizCompatBundle/deprecated.php @@ -0,0 +1,8 @@ + $alias) { + \class_alias($className, $alias); +} diff --git a/lib/RoadizCompatBundle/phpcs.xml.dist b/lib/RoadizCompatBundle/phpcs.xml.dist new file mode 100644 index 00000000..19bff0cc --- /dev/null +++ b/lib/RoadizCompatBundle/phpcs.xml.dist @@ -0,0 +1,13 @@ + + + + + + + + + + + src/ + diff --git a/lib/RoadizCompatBundle/phpstan.neon b/lib/RoadizCompatBundle/phpstan.neon new file mode 100644 index 00000000..d1f675bb --- /dev/null +++ b/lib/RoadizCompatBundle/phpstan.neon @@ -0,0 +1,33 @@ +parameters: + level: 7 + paths: + - src + excludePaths: + - */node_modules/* + - */bower_components/* + - */static/* + ignoreErrors: + - '#Call to an undefined method RZ\\Roadiz\\CoreBundle\\Repository#' + - '#Call to an undefined method RZ\\Roadiz\\UserBundle\\Repository#' + - '#Call to an undefined method Doctrine\\Persistence\\ObjectRepository#' + - '#Call to an undefined method Doctrine\\Persistence\\ObjectManager#' + - '#Call to an undefined method Doctrine\\ORM\\EntityRepository#' + - '#Call to an undefined method Symfony\\Component\\Config\\Definition\\Builder\\NodeDefinition::#' + - '#Access to an undefined property Symfony\\Component\\Validator\\Constraint::#' + - '#RZ\\Roadiz\\Core\\Entities\\Tag does not have a field named \$translation#' + - '#does not have a field named \$node\.home#' + - '#does not have a field named \$node\.id#' + - '#does not have a field named \$node\.parent#' + - '#does not have a field named \$translation#' + # PHPStan Doctrine does not support ResolveTargetEntityListener + - '#Property ([a-zA-Z\\\:\$]+) type mapping mismatch: property can contain ([a-zA-Z\\\&\>\<]+)Interface\>?\|null but database expects ([a-zA-Z\\\&\>\<]+)\|null#' + - '#Property ([a-zA-Z\\\:\$]+) type mapping mismatch: property can contain ([a-zA-Z\\\&\>\<]+)Interface\>? but database expects ([a-zA-Z\\\&\>\<]+)#' + - '#type mapping mismatch: database can contain array\|bool\|float\|int\|JsonSerializable\|stdClass\|string\|null but property expects array\|null#' + - '#Doctrine\\ORM\\Mapping\\GeneratedValue constructor expects#' + + reportUnmatchedIgnoredErrors: false + checkGenericClassInNonGenericObjectType: false + checkMissingIterableValueType: false +includes: + - vendor/phpstan/phpstan-doctrine/extension.neon + - vendor/phpstan/phpstan-doctrine/rules.neon diff --git a/lib/RoadizCompatBundle/src/Aliases.php b/lib/RoadizCompatBundle/src/Aliases.php new file mode 100644 index 00000000..a8254486 --- /dev/null +++ b/lib/RoadizCompatBundle/src/Aliases.php @@ -0,0 +1,281 @@ + + */ + public static function getAliases(): array + { + return [ + \RZ\Roadiz\CompatBundle\Controller\AppController::class => \RZ\Roadiz\CMS\Controllers\AppController::class, + \RZ\Roadiz\CompatBundle\Controller\Controller::class => \RZ\Roadiz\CMS\Controllers\Controller::class, + \RZ\Roadiz\CompatBundle\Controller\FrontendController::class => \RZ\Roadiz\CMS\Controllers\FrontendController::class, + \RZ\Roadiz\CompatBundle\Theme\ThemeResolverInterface::class => \RZ\Roadiz\Utils\Theme\ThemeResolverInterface::class, + \RZ\Roadiz\CoreBundle\Bag\NodeTypes::class => \RZ\Roadiz\Core\Bags\NodeTypes::class, + \RZ\Roadiz\CoreBundle\Bag\Roles::class => \RZ\Roadiz\Core\Bags\Roles::class, + \RZ\Roadiz\CoreBundle\Bag\Settings::class => \RZ\Roadiz\Core\Bags\Settings::class, + \RZ\Roadiz\CoreBundle\Configuration\CollectionFieldConfiguration::class => \RZ\Roadiz\Config\CollectionFieldConfiguration::class, + \RZ\Roadiz\CoreBundle\Configuration\JoinNodeTypeFieldConfiguration::class => \RZ\Roadiz\Config\JoinNodeTypeFieldConfiguration::class, + \RZ\Roadiz\CoreBundle\Configuration\ProviderFieldConfiguration::class => \RZ\Roadiz\Config\ProviderFieldConfiguration::class, + \RZ\Roadiz\CoreBundle\CustomForm\CustomFormAnswerSerializer::class => \RZ\Roadiz\Utils\CustomForm\CustomFormAnswerSerializer::class, + \RZ\Roadiz\CoreBundle\CustomForm\CustomFormHelper::class => \RZ\Roadiz\Utils\CustomForm\CustomFormHelper::class, + \RZ\Roadiz\CoreBundle\DependencyInjection\Configuration::class => \RZ\Roadiz\Config\Configuration::class, + \RZ\Roadiz\CoreBundle\Doctrine\Event\FilterNodesSourcesQueryBuilderCriteriaEvent::class => \RZ\Roadiz\Core\Events\FilterNodesSourcesQueryBuilderCriteriaEvent::class, + \RZ\Roadiz\CoreBundle\Doctrine\Event\FilterQueryBuilderCriteriaEvent::class => \RZ\Roadiz\Core\Events\FilterQueryBuilderCriteriaEvent::class, + \RZ\Roadiz\CoreBundle\Doctrine\Event\FilterQueryBuilderEvent::class => \RZ\Roadiz\Core\Events\FilterQueryBuilderEvent::class, + \RZ\Roadiz\CoreBundle\Doctrine\Event\FilterQueryCriteriaEvent::class => \RZ\Roadiz\Core\Events\FilterQueryCriteriaEvent::class, + \RZ\Roadiz\CoreBundle\Doctrine\Event\QueryBuilder\QueryBuilderApplyEvent::class => \RZ\Roadiz\Core\Events\QueryBuilder\QueryBuilderApplyEvent::class, + \RZ\Roadiz\CoreBundle\Doctrine\Event\QueryBuilder\QueryBuilderBuildEvent::class => \RZ\Roadiz\Core\Events\QueryBuilder\QueryBuilderBuildEvent::class, + \RZ\Roadiz\CoreBundle\Doctrine\Event\QueryBuilder\QueryBuilderNodesSourcesApplyEvent::class => \RZ\Roadiz\Core\Events\QueryBuilder\QueryBuilderNodesSourcesApplyEvent::class, + \RZ\Roadiz\CoreBundle\Doctrine\Event\QueryBuilder\QueryBuilderNodesSourcesBuildEvent::class => \RZ\Roadiz\Core\Events\QueryBuilder\QueryBuilderNodesSourcesBuildEvent::class, + \RZ\Roadiz\CoreBundle\Doctrine\Event\QueryBuilder\QueryBuilderSelectEvent::class => \RZ\Roadiz\Core\Events\QueryBuilder\QueryBuilderSelectEvent::class, + \RZ\Roadiz\CoreBundle\Doctrine\Event\QueryEvent::class => \RZ\Roadiz\Core\Events\QueryEvent::class, + \RZ\Roadiz\CoreBundle\Doctrine\Event\QueryNodesSourcesEvent::class => \RZ\Roadiz\Core\Events\QueryNodesSourcesEvent::class, + \RZ\Roadiz\CoreBundle\Doctrine\SchemaUpdater::class => \RZ\Roadiz\Utils\Doctrine\SchemaUpdater::class, + \RZ\Roadiz\CoreBundle\Document\DocumentFactory::class => \RZ\Roadiz\Utils\Document\DocumentFactory::class, + \RZ\Roadiz\CoreBundle\EntityApi\NodeApi::class => \RZ\Roadiz\CMS\Utils\NodeApi::class, + \RZ\Roadiz\CoreBundle\EntityApi\NodeSourceApi::class => \RZ\Roadiz\CMS\Utils\NodeSourceApi::class, + \RZ\Roadiz\CoreBundle\EntityApi\NodeTypeApi::class => \RZ\Roadiz\CMS\Utils\NodeTypeApi::class, + \RZ\Roadiz\CoreBundle\EntityApi\TagApi::class => \RZ\Roadiz\CMS\Utils\TagApi::class, + \RZ\Roadiz\CoreBundle\EntityHandler\DocumentHandler::class => \RZ\Roadiz\Core\Handlers\DocumentHandler::class, + \RZ\Roadiz\CoreBundle\EntityHandler\FolderHandler::class => \RZ\Roadiz\Core\Handlers\FolderHandler::class, + \RZ\Roadiz\CoreBundle\EntityHandler\NodeHandler::class => \RZ\Roadiz\Core\Handlers\NodeHandler::class, + \RZ\Roadiz\CoreBundle\EntityHandler\NodeTypeHandler::class => \RZ\Roadiz\Core\Handlers\NodeTypeHandler::class, + \RZ\Roadiz\CoreBundle\EntityHandler\NodesSourcesHandler::class => \RZ\Roadiz\Core\Handlers\NodesSourcesHandler::class, + \RZ\Roadiz\CoreBundle\EntityHandler\TagHandler::class => \RZ\Roadiz\Core\Handlers\TagHandler::class, + \RZ\Roadiz\CoreBundle\EntityHandler\TranslationHandler::class => \RZ\Roadiz\Core\Handlers\TranslationHandler::class, + \RZ\Roadiz\CoreBundle\Entity\Attribute::class => \RZ\Roadiz\Core\Entities\Attribute::class, + \RZ\Roadiz\CoreBundle\Entity\AttributeDocuments::class => \RZ\Roadiz\Core\Entities\AttributeDocuments::class, + \RZ\Roadiz\CoreBundle\Entity\AttributeGroup::class => \RZ\Roadiz\Core\Entities\AttributeGroup::class, + \RZ\Roadiz\CoreBundle\Entity\AttributeGroupTranslation::class => \RZ\Roadiz\Core\Entities\AttributeGroupTranslation::class, + \RZ\Roadiz\CoreBundle\Entity\AttributeTranslation::class => \RZ\Roadiz\Core\Entities\AttributeTranslation::class, + \RZ\Roadiz\CoreBundle\Entity\AttributeValue::class => \RZ\Roadiz\Core\Entities\AttributeValue::class, + \RZ\Roadiz\CoreBundle\Entity\AttributeValueTranslation::class => \RZ\Roadiz\Core\Entities\AttributeValueTranslation::class, + \RZ\Roadiz\CoreBundle\Entity\CustomForm::class => \RZ\Roadiz\Core\Entities\CustomForm::class, + \RZ\Roadiz\CoreBundle\Entity\CustomFormAnswer::class => \RZ\Roadiz\Core\Entities\CustomFormAnswer::class, + \RZ\Roadiz\CoreBundle\Entity\CustomFormField::class => \RZ\Roadiz\Core\Entities\CustomFormField::class, + \RZ\Roadiz\CoreBundle\Entity\CustomFormFieldAttribute::class => \RZ\Roadiz\Core\Entities\CustomFormFieldAttribute::class, + \RZ\Roadiz\CoreBundle\Entity\Document::class => \RZ\Roadiz\Core\Entities\Document::class, + \RZ\Roadiz\CoreBundle\Entity\DocumentTranslation::class => \RZ\Roadiz\Core\Entities\DocumentTranslation::class, + \RZ\Roadiz\CoreBundle\Entity\Folder::class => \RZ\Roadiz\Core\Entities\Folder::class, + \RZ\Roadiz\CoreBundle\Entity\FolderTranslation::class => \RZ\Roadiz\Core\Entities\FolderTranslation::class, + \RZ\Roadiz\CoreBundle\Entity\Group::class => \RZ\Roadiz\Core\Entities\Group::class, + \RZ\Roadiz\CoreBundle\Entity\Log::class => \RZ\Roadiz\Core\Entities\Log::class, + \RZ\Roadiz\CoreBundle\Entity\LoginAttempt::class => \RZ\Roadiz\Core\Entities\LoginAttempt::class, + \RZ\Roadiz\CoreBundle\Entity\Node::class => \RZ\Roadiz\Core\Entities\Node::class, + \RZ\Roadiz\CoreBundle\Entity\NodeType::class => \RZ\Roadiz\Core\Entities\NodeType::class, + \RZ\Roadiz\CoreBundle\Entity\NodeTypeField::class => \RZ\Roadiz\Core\Entities\NodeTypeField::class, + \RZ\Roadiz\CoreBundle\Entity\NodesCustomForms::class => \RZ\Roadiz\Core\Entities\NodesCustomForms::class, + \RZ\Roadiz\CoreBundle\Entity\NodesSources::class => \RZ\Roadiz\Core\Entities\NodesSources::class, + \RZ\Roadiz\CoreBundle\Entity\NodesSourcesDocuments::class => \RZ\Roadiz\Core\Entities\NodesSourcesDocuments::class, + \RZ\Roadiz\CoreBundle\Entity\NodesToNodes::class => \RZ\Roadiz\Core\Entities\NodesToNodes::class, + \RZ\Roadiz\CoreBundle\Entity\Redirection::class => \RZ\Roadiz\Core\Entities\Redirection::class, + \RZ\Roadiz\CoreBundle\Entity\Role::class => \RZ\Roadiz\Core\Entities\Role::class, + \RZ\Roadiz\CoreBundle\Entity\Setting::class => \RZ\Roadiz\Core\Entities\Setting::class, + \RZ\Roadiz\CoreBundle\Entity\SettingGroup::class => \RZ\Roadiz\Core\Entities\SettingGroup::class, + \RZ\Roadiz\CoreBundle\Entity\Tag::class => \RZ\Roadiz\Core\Entities\Tag::class, + \RZ\Roadiz\CoreBundle\Entity\TagTranslation::class => \RZ\Roadiz\Core\Entities\TagTranslation::class, + \RZ\Roadiz\CoreBundle\Entity\TagTranslationDocuments::class => \RZ\Roadiz\Core\Entities\TagTranslationDocuments::class, + \RZ\Roadiz\CoreBundle\Entity\Theme::class => \RZ\Roadiz\Core\Entities\Theme::class, + \RZ\Roadiz\CoreBundle\Entity\Translation::class => \RZ\Roadiz\Core\Entities\Translation::class, + \RZ\Roadiz\CoreBundle\Entity\UrlAlias::class => \RZ\Roadiz\Core\Entities\UrlAlias::class, + \RZ\Roadiz\CoreBundle\Entity\User::class => \RZ\Roadiz\Core\Entities\User::class, + \RZ\Roadiz\CoreBundle\Entity\UserLogEntry::class => \RZ\Roadiz\Core\Entities\UserLogEntry::class, + \RZ\Roadiz\CoreBundle\Entity\Webhook::class => \RZ\Roadiz\Webhook\Entity\Webhook::class, + \RZ\Roadiz\Documents\Events\CachePurgeAssetsRequestEvent::class => \RZ\Roadiz\Core\Events\Cache\CachePurgeAssetsRequestEvent::class, + \RZ\Roadiz\CoreBundle\Event\Cache\CachePurgeRequestEvent::class => \RZ\Roadiz\Core\Events\Cache\CachePurgeRequestEvent::class, + \RZ\Roadiz\CoreBundle\Event\Document\DocumentTranslationUpdatedEvent::class => \RZ\Roadiz\Core\Events\DocumentTranslationUpdatedEvent::class, + \RZ\Roadiz\CoreBundle\Event\FilterCacheEvent::class => \RZ\Roadiz\Core\Events\FilterCacheEvent::class, + \RZ\Roadiz\CoreBundle\Event\FilterFolderEvent::class => \RZ\Roadiz\Core\Events\FilterFolderEvent::class, + \RZ\Roadiz\CoreBundle\Event\FilterNodeEvent::class => \RZ\Roadiz\Core\Events\FilterNodeEvent::class, + \RZ\Roadiz\CoreBundle\Event\FilterNodePathEvent::class => \RZ\Roadiz\Core\Events\FilterNodePathEvent::class, + \RZ\Roadiz\CoreBundle\Event\FilterNodesSourcesEvent::class => \RZ\Roadiz\Core\Events\FilterNodesSourcesEvent::class, + \RZ\Roadiz\CoreBundle\Event\FilterTagEvent::class => \RZ\Roadiz\Core\Events\FilterTagEvent::class, + \RZ\Roadiz\CoreBundle\Event\FilterTranslationEvent::class => \RZ\Roadiz\Core\Events\FilterTranslationEvent::class, + \RZ\Roadiz\CoreBundle\Event\FilterUrlAliasEvent::class => \RZ\Roadiz\Core\Events\FilterUrlAliasEvent::class, + \RZ\Roadiz\CoreBundle\Event\FilterUserEvent::class => \RZ\Roadiz\Core\Events\FilterUserEvent::class, + \RZ\Roadiz\CoreBundle\Event\Folder\FolderCreatedEvent::class => \RZ\Roadiz\Core\Events\Folder\FolderCreatedEvent::class, + \RZ\Roadiz\CoreBundle\Event\Folder\FolderDeletedEvent::class => \RZ\Roadiz\Core\Events\Folder\FolderDeletedEvent::class, + \RZ\Roadiz\CoreBundle\Event\Folder\FolderUpdatedEvent::class => \RZ\Roadiz\Core\Events\Folder\FolderUpdatedEvent::class, + \RZ\Roadiz\CoreBundle\Event\Node\NodeCreatedEvent::class => \RZ\Roadiz\Core\Events\Node\NodeCreatedEvent::class, + \RZ\Roadiz\CoreBundle\Event\Node\NodeDeletedEvent::class => \RZ\Roadiz\Core\Events\Node\NodeDeletedEvent::class, + \RZ\Roadiz\CoreBundle\Event\Node\NodeDuplicatedEvent::class => \RZ\Roadiz\Core\Events\Node\NodeDuplicatedEvent::class, + \RZ\Roadiz\CoreBundle\Event\Node\NodePathChangedEvent::class => \RZ\Roadiz\Core\Events\Node\NodePathChangedEvent::class, + \RZ\Roadiz\CoreBundle\Event\Node\NodeTaggedEvent::class => \RZ\Roadiz\Core\Events\Node\NodeTaggedEvent::class, + \RZ\Roadiz\CoreBundle\Event\Node\NodeUndeletedEvent::class => \RZ\Roadiz\Core\Events\Node\NodeUndeletedEvent::class, + \RZ\Roadiz\CoreBundle\Event\Node\NodeUpdatedEvent::class => \RZ\Roadiz\Core\Events\Node\NodeUpdatedEvent::class, + \RZ\Roadiz\CoreBundle\Event\Node\NodeVisibilityChangedEvent::class => \RZ\Roadiz\Core\Events\Node\NodeVisibilityChangedEvent::class, + \RZ\Roadiz\CoreBundle\Event\NodesSources\NodesSourcesCreatedEvent::class => \RZ\Roadiz\Core\Events\NodesSources\NodesSourcesCreatedEvent::class, + \RZ\Roadiz\CoreBundle\Event\NodesSources\NodesSourcesDeletedEvent::class => \RZ\Roadiz\Core\Events\NodesSources\NodesSourcesDeletedEvent::class, + \RZ\Roadiz\CoreBundle\Event\NodesSources\NodesSourcesIndexingEvent::class => \RZ\Roadiz\Core\Events\NodesSources\NodesSourcesIndexingEvent::class, + \RZ\Roadiz\CoreBundle\Event\NodesSources\NodesSourcesPathGeneratingEvent::class => \RZ\Roadiz\Core\Events\NodesSources\NodesSourcesPathGeneratingEvent::class, + \RZ\Roadiz\CoreBundle\Event\NodesSources\NodesSourcesPreUpdatedEvent::class => \RZ\Roadiz\Core\Events\NodesSources\NodesSourcesPreUpdatedEvent::class, + \RZ\Roadiz\CoreBundle\Event\NodesSources\NodesSourcesUpdatedEvent::class => \RZ\Roadiz\Core\Events\NodesSources\NodesSourcesUpdatedEvent::class, + \RZ\Roadiz\CoreBundle\Event\Role\PreCreatedRoleEvent::class => \RZ\Roadiz\Core\Events\Role\PreCreatedRoleEvent::class, + \RZ\Roadiz\CoreBundle\Event\Role\PreDeletedRoleEvent::class => \RZ\Roadiz\Core\Events\Role\PreDeletedRoleEvent::class, + \RZ\Roadiz\CoreBundle\Event\Role\PreUpdatedRoleEvent::class => \RZ\Roadiz\Core\Events\Role\PreUpdatedRoleEvent::class, + \RZ\Roadiz\CoreBundle\Event\Role\RoleEvent::class => \RZ\Roadiz\Core\Events\Role\RoleEvent::class, + \RZ\Roadiz\CoreBundle\Event\Tag\TagCreatedEvent::class => \RZ\Roadiz\Core\Events\Tag\TagCreatedEvent::class, + \RZ\Roadiz\CoreBundle\Event\Tag\TagDeletedEvent::class => \RZ\Roadiz\Core\Events\Tag\TagDeletedEvent::class, + \RZ\Roadiz\CoreBundle\Event\Tag\TagUpdatedEvent::class => \RZ\Roadiz\Core\Events\Tag\TagUpdatedEvent::class, + \RZ\Roadiz\CoreBundle\Event\Translation\TranslationCreatedEvent::class => \RZ\Roadiz\Core\Events\Translation\TranslationCreatedEvent::class, + \RZ\Roadiz\CoreBundle\Event\Translation\TranslationDeletedEvent::class => \RZ\Roadiz\Core\Events\Translation\TranslationDeletedEvent::class, + \RZ\Roadiz\CoreBundle\Event\Translation\TranslationUpdatedEvent::class => \RZ\Roadiz\Core\Events\Translation\TranslationUpdatedEvent::class, + \RZ\Roadiz\CoreBundle\Event\UrlAlias\UrlAliasCreatedEvent::class => \RZ\Roadiz\Core\Events\UrlAlias\UrlAliasCreatedEvent::class, + \RZ\Roadiz\CoreBundle\Event\UrlAlias\UrlAliasDeletedEvent::class => \RZ\Roadiz\Core\Events\UrlAlias\UrlAliasDeletedEvent::class, + \RZ\Roadiz\CoreBundle\Event\UrlAlias\UrlAliasUpdatedEvent::class => \RZ\Roadiz\Core\Events\UrlAlias\UrlAliasUpdatedEvent::class, + \RZ\Roadiz\CoreBundle\Event\User\UserCreatedEvent::class => \RZ\Roadiz\Core\Events\User\UserCreatedEvent::class, + \RZ\Roadiz\CoreBundle\Event\User\UserDeletedEvent::class => \RZ\Roadiz\Core\Events\User\UserDeletedEvent::class, + \RZ\Roadiz\CoreBundle\Event\User\UserDisabledEvent::class => \RZ\Roadiz\Core\Events\User\UserDisabledEvent::class, + \RZ\Roadiz\CoreBundle\Event\User\UserEnabledEvent::class => \RZ\Roadiz\Core\Events\User\UserEnabledEvent::class, + \RZ\Roadiz\CoreBundle\Event\User\UserPasswordChangedEvent::class => \RZ\Roadiz\Core\Events\User\UserPasswordChangedEvent::class, + \RZ\Roadiz\CoreBundle\Event\User\UserUpdatedEvent::class => \RZ\Roadiz\Core\Events\User\UserUpdatedEvent::class, + \RZ\Roadiz\CoreBundle\Event\User\UserJoinedGroupEvent::class => \RZ\Roadiz\Core\Events\User\UserJoinedGroupEvent::class, + \RZ\Roadiz\CoreBundle\Event\User\UserLeavedGroupEvent::class => \RZ\Roadiz\Core\Events\User\UserLeavedGroupEvent::class, + \RZ\Roadiz\CoreBundle\Exception\EntityAlreadyExistsException::class => \RZ\Roadiz\Core\Exceptions\EntityAlreadyExistsException::class, + \RZ\Roadiz\CoreBundle\Exception\ForceResponseException::class => \RZ\Roadiz\Core\Exceptions\ForceResponseException::class, + \RZ\Roadiz\CoreBundle\Exception\NoTranslationAvailableException::class => \RZ\Roadiz\Core\Exceptions\NoTranslationAvailableException::class, + \RZ\Roadiz\CoreBundle\Node\Exception\SameNodeUrlException::class => \RZ\Roadiz\Utils\Node\Exception\SameNodeUrlException::class, + \RZ\Roadiz\CoreBundle\Explorer\AbstractExplorerProvider::class => \RZ\Roadiz\Explorer\AbstractExplorerProvider::class, + \RZ\Roadiz\CoreBundle\Explorer\AbstractExplorerItem::class => \RZ\Roadiz\Explorer\AbstractExplorerItem::class, + \RZ\Roadiz\CoreBundle\Explorer\AbstractDoctrineExplorerProvider::class => \RZ\Roadiz\Explorer\AbstractDoctrineExplorerProvider::class, + \RZ\Roadiz\CoreBundle\Explorer\ExplorerItemInterface::class => \RZ\Roadiz\Explorer\ExplorerItemInterface::class, + \RZ\Roadiz\CoreBundle\Explorer\ExplorerProviderInterface::class => \RZ\Roadiz\Explorer\ExplorerProviderInterface::class, + \RZ\Roadiz\CoreBundle\Form\AttributeChoiceType::class => \RZ\Roadiz\Attribute\Form\AttributeChoiceType::class, + \RZ\Roadiz\CoreBundle\Form\AttributeDocumentType::class => \RZ\Roadiz\Attribute\Form\AttributeDocumentType::class, + \RZ\Roadiz\CoreBundle\Form\AttributeGroupTranslationType::class => \RZ\Roadiz\Attribute\Form\AttributeGroupTranslationType::class, + \RZ\Roadiz\CoreBundle\Form\AttributeGroupType::class => \RZ\Roadiz\Attribute\Form\AttributeGroupType::class, + \RZ\Roadiz\CoreBundle\Form\AttributeGroupsType::class => \RZ\Roadiz\Attribute\Form\AttributeGroupsType::class, + \RZ\Roadiz\CoreBundle\Form\AttributeImportType::class => \RZ\Roadiz\Attribute\Form\AttributeImportType::class, + \RZ\Roadiz\CoreBundle\Form\AttributeTranslationType::class => \RZ\Roadiz\Attribute\Form\AttributeTranslationType::class, + \RZ\Roadiz\CoreBundle\Form\AttributeType::class => \RZ\Roadiz\Attribute\Form\AttributeType::class, + \RZ\Roadiz\CoreBundle\Form\AttributeValueTranslationType::class => \RZ\Roadiz\Attribute\Form\AttributeValueTranslationType::class, + \RZ\Roadiz\CoreBundle\Form\AttributeValueType::class => \RZ\Roadiz\Attribute\Form\AttributeValueType::class, + \RZ\Roadiz\CoreBundle\Form\ColorType::class => \RZ\Roadiz\CMS\Forms\ColorType::class, + \RZ\Roadiz\CoreBundle\Form\CompareDateType::class => \RZ\Roadiz\CMS\Forms\CompareDateType::class, + \RZ\Roadiz\CoreBundle\Form\CompareDatetimeType::class => \RZ\Roadiz\CMS\Forms\CompareDatetimeType::class, + \RZ\Roadiz\CoreBundle\Form\Constraint\HexadecimalColor::class => \RZ\Roadiz\CMS\Forms\Constraints\HexadecimalColor::class, + \RZ\Roadiz\CoreBundle\Form\Constraint\HexadecimalColorValidator::class => \RZ\Roadiz\CMS\Forms\Constraints\HexadecimalColorValidator::class, + \RZ\Roadiz\CoreBundle\Form\Constraint\NodeTypeField::class => \RZ\Roadiz\CMS\Forms\Constraints\NodeTypeField::class, + \RZ\Roadiz\CoreBundle\Form\Constraint\NodeTypeFieldValidator::class => \RZ\Roadiz\CMS\Forms\Constraints\NodeTypeFieldValidator::class, + \RZ\Roadiz\CoreBundle\Form\Constraint\NonSqlReservedWord::class => \RZ\Roadiz\CMS\Forms\Constraints\NonSqlReservedWord::class, + \RZ\Roadiz\CoreBundle\Form\Constraint\NonSqlReservedWordValidator::class => \RZ\Roadiz\CMS\Forms\Constraints\NonSqlReservedWordValidator::class, + \RZ\Roadiz\CoreBundle\Form\Constraint\Recaptcha::class => \RZ\Roadiz\CMS\Forms\Constraints\Recaptcha::class, + \RZ\Roadiz\CoreBundle\Form\Constraint\RecaptchaValidator::class => \RZ\Roadiz\CMS\Forms\Constraints\RecaptchaValidator::class, + \RZ\Roadiz\CoreBundle\Form\Constraint\SimpleLatinString::class => \RZ\Roadiz\CMS\Forms\Constraints\SimpleLatinString::class, + \RZ\Roadiz\CoreBundle\Form\Constraint\SimpleLatinStringValidator::class => \RZ\Roadiz\CMS\Forms\Constraints\SimpleLatinStringValidator::class, + \RZ\Roadiz\CoreBundle\Form\Constraint\UniqueEntity::class => \RZ\Roadiz\CMS\Forms\Constraints\UniqueEntity::class, + \RZ\Roadiz\CoreBundle\Form\Constraint\UniqueEntityValidator::class => \RZ\Roadiz\CMS\Forms\Constraints\UniqueEntityValidator::class, + \RZ\Roadiz\CoreBundle\Form\Constraint\UniqueFilename::class => \RZ\Roadiz\CMS\Forms\Constraints\UniqueFilename::class, + \RZ\Roadiz\CoreBundle\Form\Constraint\UniqueFilenameValidator::class => \RZ\Roadiz\CMS\Forms\Constraints\UniqueFilenameValidator::class, + \RZ\Roadiz\CoreBundle\Form\Constraint\UniqueNodeName::class => \RZ\Roadiz\CMS\Forms\Constraints\UniqueNodeName::class, + \RZ\Roadiz\CoreBundle\Form\Constraint\UniqueNodeNameValidator::class => \RZ\Roadiz\CMS\Forms\Constraints\UniqueNodeNameValidator::class, + \RZ\Roadiz\CoreBundle\Form\Constraint\UniqueTagName::class => \RZ\Roadiz\CMS\Forms\Constraints\UniqueTagName::class, + \RZ\Roadiz\CoreBundle\Form\Constraint\UniqueTagNameValidator::class => \RZ\Roadiz\CMS\Forms\Constraints\UniqueTagNameValidator::class, + \RZ\Roadiz\CoreBundle\Form\Constraint\ValidAccountConfirmationToken::class => \RZ\Roadiz\CMS\Forms\Constraints\ValidAccountConfirmationToken::class, + \RZ\Roadiz\CoreBundle\Form\Constraint\ValidAccountConfirmationTokenValidator::class => \RZ\Roadiz\CMS\Forms\Constraints\ValidAccountConfirmationTokenValidator::class, + \RZ\Roadiz\CoreBundle\Form\Constraint\ValidAccountEmail::class => \RZ\Roadiz\CMS\Forms\Constraints\ValidAccountEmail::class, + \RZ\Roadiz\CoreBundle\Form\Constraint\ValidAccountEmailValidator::class => \RZ\Roadiz\CMS\Forms\Constraints\ValidAccountEmailValidator::class, + \RZ\Roadiz\CoreBundle\Form\Constraint\ValidFacebookName::class => \RZ\Roadiz\CMS\Forms\Constraints\ValidFacebookName::class, + \RZ\Roadiz\CoreBundle\Form\Constraint\ValidFacebookNameValidator::class => \RZ\Roadiz\CMS\Forms\Constraints\ValidFacebookNameValidator::class, + \RZ\Roadiz\CoreBundle\Form\Constraint\ValidYaml::class => \RZ\Roadiz\CMS\Forms\Constraints\ValidYaml::class, + \RZ\Roadiz\CoreBundle\Form\Constraint\ValidYamlValidator::class => \RZ\Roadiz\CMS\Forms\Constraints\ValidYamlValidator::class, + \RZ\Roadiz\CoreBundle\Form\CreatePasswordType::class => \RZ\Roadiz\CMS\Forms\CreatePasswordType::class, + \RZ\Roadiz\CoreBundle\Form\CssType::class => \RZ\Roadiz\CMS\Forms\CssType::class, + \RZ\Roadiz\CoreBundle\Form\CustomFormsType::class => \RZ\Roadiz\CMS\Forms\CustomFormsType::class, + \RZ\Roadiz\CoreBundle\Form\DataTransformer\DocumentCollectionTransformer::class => \RZ\Roadiz\CMS\Forms\DataTransformer\DocumentCollectionTransformer::class, + \RZ\Roadiz\CoreBundle\Form\DataTransformer\EntityCollectionTransformer::class => \RZ\Roadiz\CMS\Forms\DataTransformer\EntityCollectionTransformer::class, + \RZ\Roadiz\CoreBundle\Form\DataTransformer\ExplorerProviderItemTransformer::class => \RZ\Roadiz\CMS\Forms\DataTransformer\ExplorerProviderItemTransformer::class, + \RZ\Roadiz\CoreBundle\Form\DataTransformer\FolderCollectionTransformer::class => \RZ\Roadiz\CMS\Forms\DataTransformer\FolderCollectionTransformer::class, + \RZ\Roadiz\CoreBundle\Form\DataTransformer\JoinDataTransformer::class => \RZ\Roadiz\CMS\Forms\DataTransformer\JoinDataTransformer::class, + \RZ\Roadiz\CoreBundle\Form\DataTransformer\NodeTypeTransformer::class => \RZ\Roadiz\CMS\Forms\DataTransformer\NodeTypeTransformer::class, + \RZ\Roadiz\CoreBundle\Form\DataTransformer\PersistableTransformer::class => \RZ\Roadiz\CMS\Forms\DataTransformer\PersistableTransformer::class, + \RZ\Roadiz\CoreBundle\Form\DataTransformer\ProviderDataTransformer::class => \RZ\Roadiz\CMS\Forms\DataTransformer\ProviderDataTransformer::class, + \RZ\Roadiz\CoreBundle\Form\DataTransformer\ReversePersistableTransformer::class => \RZ\Roadiz\CMS\Forms\DataTransformer\ReversePersistableTransformer::class, + \RZ\Roadiz\CoreBundle\Form\DataTransformer\TagTranslationDocumentsTransformer::class => \RZ\Roadiz\CMS\Forms\DataTransformer\TagTranslationDocumentsTransformer::class, + \RZ\Roadiz\CoreBundle\Form\DataTransformer\TranslationTransformer::class => \RZ\Roadiz\CMS\Forms\DataTransformer\TranslationTransformer::class, + \RZ\Roadiz\CoreBundle\Form\DocumentCollectionType::class => \RZ\Roadiz\CMS\Forms\DocumentCollectionType::class, + \RZ\Roadiz\CoreBundle\Form\EnumerationType::class => \RZ\Roadiz\CMS\Forms\EnumerationType::class, + \RZ\Roadiz\CoreBundle\Form\ExplorerProviderItemType::class => \RZ\Roadiz\CMS\Forms\ExplorerProviderItemType::class, + \RZ\Roadiz\CoreBundle\Form\ExtendedBooleanType::class => \RZ\Roadiz\CMS\Forms\ExtendedBooleanType::class, + \RZ\Roadiz\CoreBundle\Form\GroupsType::class => \RZ\Roadiz\CMS\Forms\GroupsType::class, + \RZ\Roadiz\CoreBundle\Form\HoneypotType::class => \RZ\Roadiz\CMS\Forms\HoneypotType::class, + \RZ\Roadiz\CoreBundle\Form\JsonType::class => \RZ\Roadiz\CMS\Forms\JsonType::class, + \RZ\Roadiz\CoreBundle\Form\LoginRequestForm::class => \RZ\Roadiz\CMS\Forms\LoginRequestForm::class, + \RZ\Roadiz\CoreBundle\Form\LoginResetForm::class => \RZ\Roadiz\CMS\Forms\LoginResetForm::class, + \RZ\Roadiz\CoreBundle\Form\MarkdownType::class => \RZ\Roadiz\CMS\Forms\MarkdownType::class, + \RZ\Roadiz\CoreBundle\Form\MultipleEnumerationType::class => \RZ\Roadiz\CMS\Forms\MultipleEnumerationType::class, + \RZ\Roadiz\CoreBundle\Form\NodeStatesType::class => \RZ\Roadiz\CMS\Forms\NodeStatesType::class, + \RZ\Roadiz\CoreBundle\Form\NodeTypesType::class => \RZ\Roadiz\CMS\Forms\NodeTypesType::class, + \RZ\Roadiz\CoreBundle\Form\NodesType::class => \RZ\Roadiz\CMS\Forms\NodesType::class, + \RZ\Roadiz\CoreBundle\Form\RecaptchaType::class => \RZ\Roadiz\CMS\Forms\RecaptchaType::class, + \RZ\Roadiz\CoreBundle\Form\RolesType::class => \RZ\Roadiz\CMS\Forms\RolesType::class, + \RZ\Roadiz\CoreBundle\Form\SeparatorType::class => \RZ\Roadiz\CMS\Forms\SeparatorType::class, + \RZ\Roadiz\CoreBundle\Form\SettingDocumentType::class => \RZ\Roadiz\CMS\Forms\SettingDocumentType::class, + \RZ\Roadiz\CoreBundle\Form\SettingGroupType::class => \RZ\Roadiz\CMS\Forms\SettingGroupType::class, + \RZ\Roadiz\CoreBundle\Form\SettingType::class => \RZ\Roadiz\CMS\Forms\SettingType::class, + \RZ\Roadiz\CoreBundle\Form\SettingTypeResolver::class => \RZ\Roadiz\CMS\Forms\SettingTypeResolver::class, + \RZ\Roadiz\CoreBundle\Form\TagTranslationDocumentType::class => \RZ\Roadiz\CMS\Forms\TagTranslationDocumentType::class, + \RZ\Roadiz\CoreBundle\Form\TagsType::class => \RZ\Roadiz\CMS\Forms\TagsType::class, + \RZ\Roadiz\CoreBundle\Form\ThemesType::class => \RZ\Roadiz\CMS\Forms\ThemesType::class, + \RZ\Roadiz\CoreBundle\Form\TranslationsType::class => \RZ\Roadiz\CMS\Forms\TranslationsType::class, + \RZ\Roadiz\CoreBundle\Form\UrlAliasType::class => \RZ\Roadiz\CMS\Forms\UrlAliasType::class, + \RZ\Roadiz\CoreBundle\Form\UsersType::class => \RZ\Roadiz\CMS\Forms\UsersType::class, + \RZ\Roadiz\CoreBundle\Form\WebhookType::class => \RZ\Roadiz\Webhook\Form\WebhookType::class, + \RZ\Roadiz\CoreBundle\Form\YamlType::class => \RZ\Roadiz\CMS\Forms\YamlType::class, + \RZ\Roadiz\CoreBundle\Importer\AttributeImporter::class => \RZ\Roadiz\Attribute\Importer\AttributeImporter::class, + \RZ\Roadiz\CoreBundle\Importer\ChainImporter::class => \RZ\Roadiz\CMS\Importers\ChainImporter::class, + \RZ\Roadiz\CoreBundle\Importer\GroupsImporter::class => \RZ\Roadiz\CMS\Importers\GroupsImporter::class, + \RZ\Roadiz\CoreBundle\Importer\NodeTypesImporter::class => \RZ\Roadiz\CMS\Importers\NodeTypesImporter::class, + \RZ\Roadiz\CoreBundle\Importer\RolesImporter::class => \RZ\Roadiz\CMS\Importers\RolesImporter::class, + \RZ\Roadiz\CoreBundle\Importer\SettingsImporter::class => \RZ\Roadiz\CMS\Importers\SettingsImporter::class, + \RZ\Roadiz\CoreBundle\Importer\TagsImporter::class => \RZ\Roadiz\CMS\Importers\TagsImporter::class, + \RZ\Roadiz\CoreBundle\ListManager\EntityListManager::class => \RZ\Roadiz\Core\ListManagers\EntityListManager::class, + \RZ\Roadiz\CoreBundle\ListManager\EntityListManagerInterface::class => \RZ\Roadiz\Core\ListManagers\EntityListManagerInterface::class, + \RZ\Roadiz\CoreBundle\ListManager\NodePaginator::class => \RZ\Roadiz\Core\ListManagers\NodePaginator::class, + \RZ\Roadiz\CoreBundle\ListManager\NodesSourcesPaginator::class => \RZ\Roadiz\Core\ListManagers\NodesSourcesPaginator::class, + \RZ\Roadiz\CoreBundle\ListManager\Paginator::class => \RZ\Roadiz\Core\ListManagers\Paginator::class, + \RZ\Roadiz\CoreBundle\ListManager\QueryBuilderListManager::class => \RZ\Roadiz\Core\ListManagers\QueryBuilderListManager::class, + \RZ\Roadiz\CoreBundle\ListManager\TagListManager::class => \RZ\Roadiz\Core\ListManagers\TagListManager::class, + \RZ\Roadiz\CoreBundle\Mailer\ContactFormManager::class => \RZ\Roadiz\Utils\ContactFormManager::class, + \RZ\Roadiz\CoreBundle\Mailer\EmailManager::class => \RZ\Roadiz\Utils\EmailManager::class, + \RZ\Roadiz\CoreBundle\Node\NodeDuplicator::class => \RZ\Roadiz\Utils\Node\NodeDuplicator::class, + \RZ\Roadiz\CoreBundle\Node\NodeFactory::class => \RZ\Roadiz\Utils\Node\NodeFactory::class, + \RZ\Roadiz\CoreBundle\Node\NodeMover::class => \RZ\Roadiz\Utils\Node\NodeMover::class, + \RZ\Roadiz\CoreBundle\Node\NodeNameChecker::class => \RZ\Roadiz\Utils\Node\NodeNameChecker::class, + \RZ\Roadiz\CoreBundle\Node\NodeNamePolicyInterface::class => \RZ\Roadiz\Utils\Node\NodeNamePolicyInterface::class, + \RZ\Roadiz\CoreBundle\Node\NodeTranstyper::class => \RZ\Roadiz\Utils\Node\NodeTranstyper::class, + \RZ\Roadiz\CoreBundle\Node\UniqueNodeGenerator::class => \RZ\Roadiz\Utils\Node\UniqueNodeGenerator::class, + \RZ\Roadiz\CoreBundle\Node\UniversalDataDuplicator::class => \RZ\Roadiz\Utils\Node\UniversalDataDuplicator::class, + \RZ\Roadiz\CoreBundle\Preview\PreviewResolverInterface::class => \RZ\Roadiz\Preview\PreviewResolverInterface::class, + \RZ\Roadiz\CoreBundle\Repository\DocumentRepository::class => \RZ\Roadiz\Core\Repositories\DocumentRepository::class, + \RZ\Roadiz\CoreBundle\Repository\EntityRepository::class => \RZ\Roadiz\Core\Repositories\EntityRepository::class, + \RZ\Roadiz\CoreBundle\Repository\NodeRepository::class => \RZ\Roadiz\Core\Repositories\NodeRepository::class, + \RZ\Roadiz\CoreBundle\Repository\TagRepository::class => \RZ\Roadiz\Core\Repositories\TagRepository::class, + \RZ\Roadiz\CoreBundle\Repository\TranslationRepository::class => \RZ\Roadiz\Core\Repositories\TranslationRepository::class, + \RZ\Roadiz\CoreBundle\Routing\NodeRouteHelper::class => \RZ\Roadiz\Core\Routing\NodeRouteHelper::class, + \RZ\Roadiz\CoreBundle\Routing\NodeRouter::class => \RZ\Roadiz\Core\Routing\NodeRouter::class, + \RZ\Roadiz\CoreBundle\SearchEngine\GlobalNodeSourceSearchHandler::class => \RZ\Roadiz\Core\SearchEngine\GlobalNodeSourceSearchHandler::class, + \RZ\Roadiz\CoreBundle\SearchEngine\NodeSourceSearchHandlerInterface::class => \RZ\Roadiz\Core\SearchEngine\NodeSourceSearchHandlerInterface::class, + \RZ\Roadiz\CoreBundle\Security\Authorization\Chroot\NodeChrootResolver::class => \RZ\Roadiz\Core\Authorization\Chroot\NodeChrootResolver::class, + \RZ\Roadiz\CoreBundle\Tag\TagFactory::class => \RZ\Roadiz\Utils\Tag\TagFactory::class, + \RZ\Roadiz\CoreBundle\Traits\LoginResetTrait::class => \RZ\Roadiz\CMS\Traits\LoginResetTrait::class, + \RZ\Roadiz\CoreBundle\Webhook\WebhookDispatcher::class => \RZ\Roadiz\Webhook\WebhookDispatcher::class, + \RZ\Roadiz\CoreBundle\Webhook\Exception\TooManyWebhookTriggeredException::class => \RZ\Roadiz\Webhook\Exception\TooManyWebhookTriggeredException::class, + \RZ\Roadiz\CoreBundle\Xlsx\AbstractXlsxSerializer::class => \RZ\Roadiz\Core\Serializers\AbstractXlsxSerializer::class, + \RZ\Roadiz\CoreBundle\Xlsx\NodeSourceXlsxSerializer::class => \RZ\Roadiz\Core\Serializers\NodeSourceXlsxSerializer::class, + \RZ\Roadiz\CoreBundle\Xlsx\SerializerInterface::class => \RZ\Roadiz\Core\Serializers\SerializerInterface::class, + \RZ\Roadiz\CoreBundle\Xlsx\XlsxExporter::class => \RZ\Roadiz\Utils\XlsxExporter::class, + \Symfony\Component\HttpKernel\Kernel::class => \RZ\Roadiz\Core\Kernel::class, + \RZ\Roadiz\CoreBundle\Document\MediaFinder\SoundcloudEmbedFinder::class => \RZ\Roadiz\Utils\MediaFinders\SoundcloudEmbedFinder::class, + \RZ\Roadiz\CoreBundle\Document\MediaFinder\YoutubeEmbedFinder::class => \RZ\Roadiz\Utils\MediaFinders\YoutubeEmbedFinder::class, + \RZ\Roadiz\CoreBundle\Document\MediaFinder\VimeoEmbedFinder::class => \RZ\Roadiz\Utils\MediaFinders\VimeoEmbedFinder::class, + \RZ\Roadiz\CoreBundle\Document\MediaFinder\DailymotionEmbedFinder::class => \RZ\Roadiz\Utils\MediaFinders\DailymotionEmbedFinder::class, + ]; + } +} diff --git a/lib/RoadizCompatBundle/src/Console/ThemeAssetsCommand.php b/lib/RoadizCompatBundle/src/Console/ThemeAssetsCommand.php new file mode 100644 index 00000000..7bb5128a --- /dev/null +++ b/lib/RoadizCompatBundle/src/Console/ThemeAssetsCommand.php @@ -0,0 +1,79 @@ +projectDir = $projectDir; + $this->themeGenerator = $themeGenerator; + } + + protected function configure(): void + { + $this->setName('themes:assets:install') + ->setDescription('Install a theme assets folder in public directory.') + ->addArgument( + 'name', + InputArgument::REQUIRED, + 'Theme name (without the "Theme" suffix) or full-qualified ThemeApp class name (you can use / instead of \\).' + ) + ->addOption('symlink', null, InputOption::VALUE_NONE, 'Symlinks the theme assets instead of copying it') + ->addOption('relative', null, InputOption::VALUE_NONE, 'Make relative symlinks') + ; + } + + /** + * @param InputInterface $input + * @param OutputInterface $output + * @return int + */ + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + if ($input->getOption('relative')) { + $expectedMethod = ThemeGenerator::METHOD_RELATIVE_SYMLINK; + $io->writeln('Trying to install theme assets as relative symbolic link.'); + } elseif ($input->getOption('symlink')) { + $expectedMethod = ThemeGenerator::METHOD_ABSOLUTE_SYMLINK; + $io->writeln('Trying to install theme assets as absolute symbolic link.'); + } else { + $expectedMethod = ThemeGenerator::METHOD_COPY; + $io->writeln('Installing theme assets as hard copy.'); + } + $name = str_replace('/', '\\', $input->getArgument('name')); + + $themeInfo = new ThemeInfo($name, $this->projectDir); + + if ($themeInfo->exists()) { + $io->table([ + 'Description', 'Value' + ], [ + ['Given name', $themeInfo->getName()], + ['Theme path', $themeInfo->getThemePath()], + ['Assets path', $themeInfo->getThemePath() . '/static'], + ]); + + $this->themeGenerator->installThemeAssets($themeInfo, $expectedMethod); + return 0; + } + throw new InvalidArgumentException($themeInfo->getThemePath() . ' does not exist.'); + } +} diff --git a/lib/RoadizCompatBundle/src/Console/ThemeGenerateCommand.php b/lib/RoadizCompatBundle/src/Console/ThemeGenerateCommand.php new file mode 100644 index 00000000..07af21f5 --- /dev/null +++ b/lib/RoadizCompatBundle/src/Console/ThemeGenerateCommand.php @@ -0,0 +1,106 @@ +projectDir = $projectDir; + $this->themeGenerator = $themeGenerator; + } + + protected function configure(): void + { + $this->setName('themes:generate') + ->setDescription('Generate a new theme based on BaseTheme boilerplate. Requires "find", "sed" and "git" commands.') + ->addArgument( + 'name', + InputArgument::REQUIRED, + 'Theme name (without the "Theme" suffix)' + ) + ->addOption( + 'develop', + 'd', + InputOption::VALUE_NONE, + 'Use BaseTheme develop branch instead of master.' + ) + ->addOption( + 'branch', + 'b', + InputOption::VALUE_REQUIRED, + 'Choose BaseTheme branch.' + ) + ->addOption('symlink', null, InputOption::VALUE_NONE, 'Symlinks the theme assets instead of copying it') + ->addOption('relative', null, InputOption::VALUE_NONE, 'Make relative symlinks') + ; + } + + /** + * @param InputInterface $input + * @param OutputInterface $output + * @return int + */ + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $branch = 'master'; + if ($input->getOption('develop')) { + $branch = 'develop'; + } + if ($input->getOption('branch')) { + $branch = $input->getOption('branch'); + } + if ($input->getOption('relative')) { + $expectedMethod = ThemeGenerator::METHOD_RELATIVE_SYMLINK; + } elseif ($input->getOption('symlink')) { + $expectedMethod = ThemeGenerator::METHOD_ABSOLUTE_SYMLINK; + } else { + $expectedMethod = ThemeGenerator::METHOD_COPY; + } + + $name = str_replace('/', '\\', $input->getArgument('name')); + $themeInfo = new ThemeInfo($name, $this->projectDir); + + if ( + $io->confirm( + 'Are you sure you want to generate a new theme called: "' . $themeInfo->getThemeName() . '"' . + ' using ' . $branch . ' branch and installing its assets with ' . $expectedMethod . ' method?', + false + ) + ) { + if (!$themeInfo->exists()) { + $this->themeGenerator->downloadTheme($themeInfo, $branch); + $io->success('BaseTheme cloned into ' . $themeInfo->getThemePath()); + } + + $this->themeGenerator->renameTheme($themeInfo); + $this->themeGenerator->installThemeAssets($themeInfo, $expectedMethod); + + $io->note([ + 'Register your theme into your config/packages/roadiz_core.yaml configuration file', + '---', + 'themes:', + ' - classname: ' . $themeInfo->getClassname(), + ]); + $io->success($themeInfo->getThemeName() . ' has been regenerated and is ready to be installed, have fun!'); + } + + return 0; + } +} diff --git a/lib/RoadizCompatBundle/src/Console/ThemeInfoCommand.php b/lib/RoadizCompatBundle/src/Console/ThemeInfoCommand.php new file mode 100644 index 00000000..da21b770 --- /dev/null +++ b/lib/RoadizCompatBundle/src/Console/ThemeInfoCommand.php @@ -0,0 +1,67 @@ +projectDir = $projectDir; + $this->themeGenerator = $themeGenerator; + } + + protected function configure(): void + { + $this->setName('themes:info') + ->setDescription('Get information from a Theme.') + ->addArgument( + 'name', + InputArgument::REQUIRED, + 'Theme name (without the "Theme" suffix) or full-qualified ThemeApp class name (you can use / instead of \\).' + ) + ; + } + + /** + * @param InputInterface $input + * @param OutputInterface $output + * @return int + */ + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $name = str_replace('/', '\\', $input->getArgument('name')); + $themeInfo = new ThemeInfo($name, $this->projectDir); + + if ($themeInfo->exists()) { + if (!$themeInfo->isValid()) { + throw new InvalidArgumentException($themeInfo->getClassname() . ' is not a valid theme.'); + } + $io->table([ + 'Description', 'Value' + ], [ + ['Given name', $themeInfo->getName()], + ['Theme classname', $themeInfo->getClassname()], + ['Theme path', $themeInfo->getThemePath()], + ['Assets path', $themeInfo->getThemePath() . '/static'], + ]); + return 0; + } + throw new InvalidArgumentException($themeInfo->getClassname() . ' does not exist.'); + } +} diff --git a/lib/RoadizCompatBundle/src/Console/ThemeInstallCommand.php b/lib/RoadizCompatBundle/src/Console/ThemeInstallCommand.php new file mode 100644 index 00000000..906b842d --- /dev/null +++ b/lib/RoadizCompatBundle/src/Console/ThemeInstallCommand.php @@ -0,0 +1,240 @@ +projectDir = $projectDir; + $this->themeGenerator = $themeGenerator; + $this->nodeTypesImporter = $nodeTypesImporter; + $this->tagsImporter = $tagsImporter; + $this->settingsImporter = $settingsImporter; + $this->rolesImporter = $rolesImporter; + $this->groupsImporter = $groupsImporter; + $this->attributeImporter = $attributeImporter; + $this->managerRegistry = $managerRegistry; + } + + protected function configure(): void + { + $this->setName('themes:install') + ->setDescription('Manage themes installation') + ->addArgument( + 'classname', + InputArgument::REQUIRED, + 'Main theme classname (Use / instead of \\ and do not forget starting slash) or path to config.yml' + ) + ->addOption( + 'data', + null, + InputOption::VALUE_NONE, + 'Import default data (node-types, roles, settings and tags)' + ) + ->addOption( + 'dry-run', + 'd', + InputOption::VALUE_NONE, + 'Do nothing, only print information.' + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + if ($input->getOption('dry-run')) { + $this->dryRun = true; + } + $this->io = new SymfonyStyle($input, $output); + $themeInfo = null; + + /* + * Test if Classname is not a valid yaml file before using Theme + */ + if ((new UnicodeString($input->getArgument('classname')))->endsWith('config.yml')) { + $classname = realpath($input->getArgument('classname')); + if (false !== $classname && file_exists($classname)) { + $this->io->note('Install assets directly from file: ' . $classname); + $themeConfigPath = $classname; + } else { + $this->io->error($classname . ' configuration file is not readable.'); + return 1; + } + } else { + /* + * Replace slash by anti-slashes + */ + $classname = str_replace('/', '\\', $input->getArgument('classname')); + $themeInfo = new ThemeInfo($classname, $this->projectDir); + $themeConfigPath = $themeInfo->getThemePath() . '/config.yml'; + if (!$themeInfo->isValid()) { + throw new RuntimeException($themeInfo->getClassname() . ' is not a valid Roadiz theme.'); + } + if (!file_exists($themeConfigPath)) { + $this->io->warning($themeInfo->getName() . ' theme does not have any configuration.'); + return 1; + } + } + + if ($output->isVeryVerbose() && null !== $themeInfo) { + $this->io->writeln('Theme name is: ' . $themeInfo->getName() . '.'); + $this->io->writeln('Theme assets are located in ' . $themeInfo->getThemePath() . '/static.'); + } + + if ($input->getOption('data')) { + $this->importThemeData($themeInfo, $themeConfigPath); + } else { + $this->io->note( + 'Roadiz themes are no more registered into database. ' . + 'You should use --data or --nodes option.' + ); + } + return 0; + } + + protected function importThemeData(?ThemeInfo $themeInfo, string $themeConfigPath): void + { + $data = $this->getThemeConfig($themeConfigPath); + + if (false !== $data && isset($data["importFiles"])) { + if (isset($data["importFiles"]['groups'])) { + foreach ($data["importFiles"]['groups'] as $filename) { + $this->importFile($themeInfo, $filename, $this->groupsImporter); + } + } + if (isset($data["importFiles"]['roles'])) { + foreach ($data["importFiles"]['roles'] as $filename) { + $this->importFile($themeInfo, $filename, $this->rolesImporter); + } + } + if (isset($data["importFiles"]['settings'])) { + foreach ($data["importFiles"]['settings'] as $filename) { + $this->importFile($themeInfo, $filename, $this->settingsImporter); + } + } + if (isset($data["importFiles"]['nodetypes'])) { + foreach ($data["importFiles"]['nodetypes'] as $filename) { + $this->importFile($themeInfo, $filename, $this->nodeTypesImporter); + } + } + if (isset($data["importFiles"]['tags'])) { + foreach ($data["importFiles"]['tags'] as $filename) { + $this->importFile($themeInfo, $filename, $this->tagsImporter); + } + } + if (isset($data["importFiles"]['attributes'])) { + foreach ($data["importFiles"]['attributes'] as $filename) { + $this->importFile($themeInfo, $filename, $this->attributeImporter); + } + } + if ($this->io->isVeryVerbose()) { + $this->io->note( + 'You should do a `bin/console generate:nsentities`' . + ' to regenerate your node-types source classes, ' . + 'and a `bin/console doctrine:schema:update --dump-sql --force` ' . + 'to apply your changes into database.' + ); + } + } else { + $this->io->warning('Config file "' . $themeConfigPath . '" has no data to import.'); + } + } + + /** + * @param ThemeInfo|null $themeInfo + * @param string $filename + * @param EntityImporterInterface $importer + */ + protected function importFile(?ThemeInfo $themeInfo, string $filename, EntityImporterInterface $importer): void + { + if (null !== $themeInfo) { + $file = new File($themeInfo->getThemePath() . "/" . $filename); + } elseif (false !== $realFilename = realpath($filename)) { + $file = new File($realFilename); + } else { + throw new \RuntimeException($filename . ' is not a valid file'); + } + if (!$this->dryRun) { + try { + if (false === $fileContent = file_get_contents($file->getPathname())) { + throw new \RuntimeException($file->getPathname() . ' file is not readable'); + } + $importer->import($fileContent); + $this->managerRegistry->getManager()->flush(); + $this->io->writeln( + '* ' . $file->getPathname() . ' file has been imported.' + ); + return; + } catch (EntityAlreadyExistsException $e) { + $this->io->writeln( + '* ' . $file->getPathname() . '' . + ' has NOT been imported (' . $e->getMessage() . ').' + ); + } + } + $this->io->writeln( + '* ' . $file->getPathname() . ' file has been imported.' + ); + } + + /** + * @param string $themeConfigPath + * @return array + */ + protected function getThemeConfig(string $themeConfigPath): array + { + if (false === $fileContent = file_get_contents($themeConfigPath)) { + throw new \RuntimeException($themeConfigPath . ' file is not readable'); + } + return Yaml::parse($fileContent); + } +} diff --git a/lib/RoadizCompatBundle/src/Console/ThemeMigrateCommand.php b/lib/RoadizCompatBundle/src/Console/ThemeMigrateCommand.php new file mode 100644 index 00000000..4a01afb9 --- /dev/null +++ b/lib/RoadizCompatBundle/src/Console/ThemeMigrateCommand.php @@ -0,0 +1,138 @@ +projectDir = $projectDir; + } + + protected function configure(): void + { + $this->setName('themes:migrate') + ->setDescription('Update your site against theme import files, regenerate NSEntities, update database schema and clear caches.') + ->addArgument( + 'classname', + InputArgument::REQUIRED, + 'Main theme classname (Use / instead of \\ and do not forget starting slash) or path to config.yml' + ) + ->addOption( + 'dry-run', + 'd', + InputOption::VALUE_NONE, + 'Do nothing, only print information.' + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $question = new ConfirmationQuestion( + 'Are you sure to migrate against this theme? This can lead in data loss.', + !$input->isInteractive() + ); + if ($io->askQuestion($question) === false) { + $io->note('Nothing was done…'); + return 0; + } + + if ($input->getOption('dry-run')) { + $this->runCommand( + 'themes:install', + sprintf('--data "%s" --dry-run', $input->getArgument('classname')), + null, + $input->isInteractive(), + $output->isQuiet(), + ); + } else { + $this->runCommand( + 'doctrine:migrations:migrate', + '--allow-no-migration', + null, + false, + $output->isQuiet() + ) === 0 ? $io->success('doctrine:migrations:migrate') : $io->error('doctrine:migrations:migrate'); + + $this->runCommand( + 'themes:install', + sprintf('--data "%s"', $input->getArgument('classname')), + null, + $input->isInteractive(), + $output->isQuiet() + ) === 0 ? $io->success('themes:install') : $io->error('themes:install'); + + $this->runCommand( + 'generate:nsentities', + '', + null, + $input->isInteractive(), + $output->isQuiet() + ) === 0 ? $io->success('generate:nsentities') : $io->error('generate:nsentities'); + + $this->runCommand( + 'doctrine:cache:clear-metadata', + '', + null, + $input->isInteractive(), + $output->isQuiet() + ) === 0 ? $io->success('doctrine:cache:clear-metadata') : $io->error('doctrine:cache:clear-metadata'); + + $this->runCommand( + 'doctrine:schema:update', + '--dump-sql --force', + null, + $input->isInteractive(), + $output->isQuiet() + ) === 0 ? $io->success('doctrine:schema:update') : $io->error('doctrine:schema:update'); + + $this->runCommand( + 'cache:clear', + '', + null, + $input->isInteractive(), + $output->isQuiet() + ) === 0 ? $io->success('cache:clear') : $io->error('cache:clear'); + } + return 0; + } + + protected function runCommand( + string $command, + string $args = '', + ?string $environment = null, + bool $interactive = true, + bool $quiet = false + ): int { + $args .= $interactive ? '' : ' --no-interaction '; + $args .= $quiet ? ' --quiet ' : ' -v '; + $args .= is_string($environment) ? (' --env ' . $environment) : ''; + + $process = Process::fromShellCommandline( + 'php bin/console ' . $command . ' ' . $args + ); + $process->setWorkingDirectory($this->projectDir); + $process->setTty($interactive); + $process->run(); + return $process->wait(); + } +} diff --git a/lib/RoadizCompatBundle/src/Console/ThemesListCommand.php b/lib/RoadizCompatBundle/src/Console/ThemesListCommand.php new file mode 100644 index 00000000..c6bce015 --- /dev/null +++ b/lib/RoadizCompatBundle/src/Console/ThemesListCommand.php @@ -0,0 +1,88 @@ +themeResolver = $themeResolver; + $this->filesystem = new Filesystem(); + } + + + protected function configure(): void + { + $this->setName('themes:list') + ->setDescription('Installed themes') + ->addArgument( + 'classname', + InputArgument::OPTIONAL, + 'Main theme classname (Use / instead of \\ and do not forget starting slash)' + ); + } + + /** + * @param InputInterface $input + * @param OutputInterface $output + * + * @return int + */ + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $name = $input->getArgument('classname'); + + $tableContent = []; + + if ($name) { + /* + * Replace slash by anti-slashes + */ + $name = str_replace('/', '\\', $name); + $theme = $this->themeResolver->findThemeByClass($name); + if (null === $theme) { + throw new InvalidArgumentException($name . ' theme cannot be found.'); + } + $tableContent[] = [ + str_replace('\\', '/', $theme->getClassName()), + ($theme->isAvailable() ? 'X' : ''), + ($theme->isBackendTheme() ? 'Backend' : 'Frontend'), + ]; + } else { + $themes = $this->themeResolver->findAll(); + if (count($themes) > 0) { + foreach ($themes as $theme) { + $tableContent[] = [ + str_replace('\\', '/', $theme->getClassName()), + ($theme->isAvailable() ? 'X' : ''), + ($theme->isBackendTheme() ? 'Backend' : 'Frontend'), + ]; + } + } else { + $io->warning('No available themes'); + } + } + + $io->table(['Class (with / instead of \)', 'Enabled', 'Type'], $tableContent); + return 0; + } +} diff --git a/lib/RoadizCompatBundle/src/Controller/AppController.php b/lib/RoadizCompatBundle/src/Controller/AppController.php new file mode 100644 index 00000000..0a0f1bd3 --- /dev/null +++ b/lib/RoadizCompatBundle/src/Controller/AppController.php @@ -0,0 +1,651 @@ +load('routes.yml'); + } + + /** + * Return a file locator with theme + * Resource folder. + * + * @return FileLocator + * @throws ReflectionException + */ + public static function getFileLocator(): FileLocator + { + $resourcesFolder = static::getResourcesFolder(); + return new FileLocator([ + $resourcesFolder, + $resourcesFolder . '/routing', + $resourcesFolder . '/config', + ]); + } + + /** + * Return theme Resource folder according to + * main theme class inheriting AppController. + * + * Uses \ReflectionClass to resolve final theme class folder + * whether it’s located in project folder or in vendor folder. + * + * @return string + * @throws ReflectionException + */ + public static function getResourcesFolder(): string + { + return static::getThemeFolder() . '/Resources'; + } + + /** + * Return theme root folder. + * + * @return string + * @throws ReflectionException + */ + public static function getThemeFolder(): string + { + $class_info = new ReflectionClass(static::getThemeMainClass()); + if (false === $themeFilename = $class_info->getFileName()) { + throw new ThemeClassNotValidException('Theme class file is not valid or does not exist'); + } + return dirname($themeFilename); + } + + /** + * @return class-string Main theme class (FQN class with namespace) + * @throws ThemeClassNotValidException + */ + public static function getThemeMainClass(): string + { + $mainClassName = '\\Themes\\' . static::getThemeDir() . '\\' . static::getThemeMainClassName(); + if (!class_exists($mainClassName)) { + throw new ThemeClassNotValidException(sprintf('%s class does not exist', $mainClassName)); + } + return $mainClassName; + } + + /** + * @return string + */ + public static function getThemeDir(): string + { + return static::$themeDir; + } + + /** + * @return string Main theme class name + */ + public static function getThemeMainClassName(): string + { + return static::getThemeDir() . 'App'; + } + + /** + * These routes are used to extend Roadiz back-office. + * + * @return RouteCollection|null + * @throws ReflectionException + */ + public static function getBackendRoutes(): ?RouteCollection + { + $locator = static::getFileLocator(); + + try { + $loader = new YamlFileLoader($locator); + return $loader->load('backend-routes.yml'); + } catch (InvalidArgumentException $e) { + return null; + } + } + + /** + * @return string + * @throws ReflectionException + */ + public static function getTranslationsFolder(): string + { + return static::getResourcesFolder() . '/translations'; + } + + /** + * @return string + * @throws ReflectionException + */ + public static function getPublicFolder(): string + { + return static::getThemeFolder() . '/static'; + } + + /** + * @return string + * @throws ReflectionException + */ + public static function getViewsFolder(): string + { + return static::getResourcesFolder() . '/views'; + } + + /** + * @return array + */ + public function getAssignation(): array + { + return $this->assignation; + } + + /** + * Prepare base information to be rendered in twig templates. + * + * ## Available contents + * + * - request: Main request object + * - head + * - ajax: `boolean` + * - cmsVersion + * - cmsVersionNumber + * - cmsBuild + * - devMode: `boolean` + * - baseUrl + * - filesUrl + * - resourcesUrl + * - absoluteResourcesUrl + * - staticDomainName + * - ajaxToken + * - fontToken + * - universalAnalyticsId + * - session + * - messages + * - id + * - user + * - bags + * - nodeTypes (ParametersBag) + * - settings (ParametersBag) + * - roles (ParametersBag) + * - securityAuthorizationChecker + * + * @return $this + */ + public function prepareBaseAssignation() + { + /** @var KernelInterface $kernel */ + $kernel = $this->get('kernel'); + $this->assignation = [ + 'head' => [ + 'ajax' => $this->getRequest()->isXmlHttpRequest(), + 'devMode' => $kernel->isDebug(), + 'maintenanceMode' => (bool) $this->getSettingsBag()->get('maintenance_mode'), + 'universalAnalyticsId' => $this->getSettingsBag()->get('universal_analytics_id'), + 'googleTagManagerId' => $this->getSettingsBag()->get('google_tag_manager_id'), + 'baseUrl' => $this->getRequest()->getSchemeAndHttpHost() . $this->getRequest()->getBasePath(), + ] + ]; + + return $this; + } + + /** + * Return a Response with default backend 404 error page. + * + * @param string $message Additional message to describe 404 error. + * + * @return Response + * @throws LoaderError + * @throws RuntimeError + * @throws SyntaxError + */ + public function throw404($message = '') + { + $this->assignation['nodeName'] = 'error-404'; + $this->assignation['nodeTypeName'] = 'error404'; + $this->assignation['errorMessage'] = $message; + $this->assignation['title'] = $this->getTranslator()->trans('error404.title'); + $this->assignation['content'] = $this->getTranslator()->trans('error404.message'); + + return new Response( + $this->getTwig()->render('404.html.twig', $this->assignation), + Response::HTTP_NOT_FOUND, + ['content-type' => 'text/html'] + ); + } + + /** + * Return the current Theme + * + * @return Theme|null + */ + public function getTheme(): ?Theme + { + $this->getStopwatch()->start('getTheme'); + /** @var ThemeResolverInterface $themeResolver */ + $themeResolver = $this->get(ThemeResolverInterface::class); + if (null === $this->theme) { + $className = new UnicodeString(static::getCalledClass()); + while (!$className->endsWith('App')) { + $className = get_parent_class($className->toString()); + if ($className === false) { + $className = new UnicodeString(''); + break; + } + $className = new UnicodeString($className); + } + $this->theme = $themeResolver->findThemeByClass($className->toString()); + } + $this->getStopwatch()->stop('getTheme'); + return $this->theme; + } + + /** + * Publish a confirmation message in Session flash bag and + * logger interface. + * + * @param Request $request + * @param string $msg + * @param NodesSources|null $source + */ + public function publishConfirmMessage(Request $request, string $msg, ?NodesSources $source = null): void + { + $this->publishMessage($request, $msg, 'confirm', $source); + } + + /** + * Publish a message in Session flash bag and + * logger interface. + * + * @param Request $request + * @param string $msg + * @param string $level + * @param NodesSources|null $source + */ + protected function publishMessage( + Request $request, + string $msg, + string $level = "confirm", + ?NodesSources $source = null + ): void { + $session = $this->getSession(); + if ($session instanceof Session) { + $session->getFlashBag()->add($level, $msg); + } + + switch ($level) { + case 'error': + $this->getLogger()->error($msg, ['source' => $source]); + break; + default: + $this->getLogger()->info($msg, ['source' => $source]); + break; + } + } + + /** + * Returns the current session. + * + * @return SessionInterface|null + */ + public function getSession(): ?SessionInterface + { + $request = $this->getRequest(); + return null !== $request && $request->hasPreviousSession() ? $request->getSession() : null; + } + + /** + * Publish an error message in Session flash bag and + * logger interface. + * + * @param Request $request + * @param string $msg + * @param NodesSources|null $source + * @return void + */ + public function publishErrorMessage(Request $request, string $msg, NodesSources $source = null): void + { + $this->publishMessage($request, $msg, 'error', $source); + } + + /** + * Validate a request against a given ROLE_* + * and check chroot + * and throws an AccessDeniedException exception. + * + * @param mixed $attributes + * @param mixed $nodeId + * @param bool|false $includeChroot + * @return void + * + * @throws AccessDeniedException + */ + public function validateNodeAccessForRole(mixed $attributes, mixed $nodeId = null, bool $includeChroot = false): void + { + /** @var Node|null $node */ + $node = null; + /** @var User $user */ + $user = $this->getUser(); + /** @var NodeChrootResolver $chrootResolver */ + $chrootResolver = $this->get(NodeChrootResolver::class); + $chroot = $chrootResolver->getChroot($user); + + if ($this->isGranted($attributes) && $chroot === null) { + /* + * Already grant access if user is not chroot-ed. + */ + return; + } + + if ($nodeId instanceof Node) { + $node = $nodeId; + } elseif (\is_scalar($nodeId)) { + /** @var Node|null $node */ + $node = $this->em()->find(Node::class, (int) $nodeId); + } + + if (null === $node) { + throw new AccessDeniedException("You don't have access to this page"); + } + + $this->em()->refresh($node); + + /** @var NodeHandler $nodeHandler */ + $nodeHandler = $this->getHandlerFactory()->getHandler($node); + $parents = $nodeHandler->getParents(); + + if ($includeChroot) { + $parents[] = $node; + } + + if (!$this->isGranted($attributes)) { + throw new AccessDeniedException("You don't have access to this page"); + } + + if (null !== $user && $chroot !== null && !in_array($chroot, $parents, true)) { + throw new AccessDeniedException("You don't have access to this page"); + } + } + + /** + * Generate a simple view to inform visitors that website is + * currently unavailable. + * + * @param Request $request + * @return Response + */ + public function maintenanceAction(Request $request): Response + { + $this->prepareBaseAssignation(); + + return new Response( + $this->renderView('maintenance.html.twig', $this->assignation), + Response::HTTP_SERVICE_UNAVAILABLE, + ['content-type' => 'text/html'] + ); + } + + /** + * Make current response cacheable by reverse proxy and browsers. + * + * Pay attention that, some reverse proxies systems will need to remove your response + * cookies header to actually save your response. + * + * Do not cache, if + * - we are in preview mode + * - we are in debug mode + * - Request forbids cache + * - we are in maintenance mode + * - this is a sub-request + * + * @param Request $request + * @param Response $response + * @param int $minutes TTL in minutes + * @param bool $allowClientCache Allows browser level cache + * + * @return Response + */ + public function makeResponseCachable( + Request $request, + Response $response, + int $minutes, + bool $allowClientCache = false + ): Response { + /** @var Kernel $kernel */ + $kernel = $this->get('kernel'); + /** @var RequestStack $requestStack */ + $requestStack = $this->get(RequestStack::class); + $settings = $this->getSettingsBag(); + + if ( + !$this->getPreviewResolver()->isPreview() && + !$kernel->isDebug() && + $requestStack->getMainRequest() === $request && + $request->isMethodCacheable() && + $minutes > 0 && + !$settings->get('maintenance_mode', false) + ) { + header_remove('Cache-Control'); + header_remove('Vary'); + $response->headers->remove('cache-control'); + $response->headers->remove('vary'); + $response->setPublic(); + $response->setSharedMaxAge(60 * $minutes); + $response->headers->addCacheControlDirective('must-revalidate', true); + + if ($allowClientCache) { + $response->setMaxAge(60 * $minutes); + } + + $response->setVary('Accept-Encoding, X-Partial, x-requested-with'); + + if ($request->isXmlHttpRequest()) { + $response->headers->add([ + 'X-Partial' => true + ]); + } + } + + return $response; + } + + /** + * Returns a fully qualified view path for Twig rendering. + * + * @param string $view + * @param string $namespace + * @return string + */ + protected function getNamespacedView(string $view, string $namespace = ''): string + { + if ($namespace !== "" && $namespace !== "/") { + $view = '@' . $namespace . '/' . $view; + } elseif (static::getThemeDir() !== "" && $namespace !== "/") { + // when no namespace is used + // use current theme directory + $view = '@' . static::getThemeDir() . '/' . $view; + } + + return $view; + } + + /** + * @param TranslationInterface|null $translation + * @return null|Node + */ + protected function getHome(?TranslationInterface $translation = null): ?Node + { + $this->getStopwatch()->start('getHome'); + if (null === $this->homeNode) { + $nodeRepository = $this->em()->getRepository(Node::class); + if ($translation !== null) { + $this->homeNode = $nodeRepository->findHomeWithTranslation($translation); + } else { + $this->homeNode = $nodeRepository->findHomeWithDefaultTranslation(); + } + } + $this->getStopwatch()->stop('getHome'); + + return $this->homeNode; + } + + /** + * Return all Form errors as an array. + * + * @param FormInterface $form + * @return array + * @deprecated Use FormErrorSerializer::getErrorsAsArray instead + */ + protected function getErrorsAsArray(FormInterface $form): array + { + /** @var FormErrorSerializer $formErrorSerializer */ + $formErrorSerializer = $this->get(FormErrorSerializer::class); + return $formErrorSerializer->getErrorsAsArray($form); + } +} diff --git a/lib/RoadizCompatBundle/src/Controller/Controller.php b/lib/RoadizCompatBundle/src/Controller/Controller.php new file mode 100644 index 00000000..45e5bc38 --- /dev/null +++ b/lib/RoadizCompatBundle/src/Controller/Controller.php @@ -0,0 +1,505 @@ + Packages::class, + 'csrfTokenManager' => CsrfTokenManagerInterface::class, + 'defaultTranslation' => 'defaultTranslation', + 'dispatcher' => 'event_dispatcher', + 'em' => EntityManagerInterface::class, + 'event_dispatcher' => 'event_dispatcher', + 'kernel' => KernelInterface::class, + 'logger' => LoggerInterface::class, + 'nodeApi' => NodeApi::class, + 'nodeSourceApi' => NodeSourceApi::class, + 'nodeTypesBag' => NodeTypes::class, + 'requestStack' => RequestStack::class, + 'rolesBag' => Roles::class, + 'securityAuthenticationUtils' => AuthenticationUtils::class, + 'securityTokenStorage' => TokenStorageInterface::class, + 'settingsBag' => Settings::class, + 'stopwatch' => Stopwatch::class, + 'translator' => TranslatorInterface::class, + 'urlGenerator' => UrlGeneratorInterface::class, + ContactFormManager::class => ContactFormManager::class, + DocumentUrlGeneratorInterface::class => DocumentUrlGeneratorInterface::class, + EmailManager::class => EmailManager::class, + Environment::class => Environment::class, + FormErrorSerializer::class => FormErrorSerializer::class, + LoggerInterface::class => LoggerInterface::class, + NodeChrootResolver::class => NodeChrootResolver::class, + NodeFactory::class => NodeFactory::class, + NodeIndexer::class => NodeIndexer::class, + NodeSourceSearchHandlerInterface::class => NodeSourceSearchHandlerInterface::class, + OAuth2LinkGenerator::class => OAuth2LinkGenerator::class, + PreviewResolverInterface::class => PreviewResolverInterface::class, + RandomImageFinder::class => RandomImageFinder::class, + RendererInterface::class => RendererInterface::class, + RequestStack::class => RequestStack::class, + Security::class => Security::class, + Settings::class => Settings::class, + Stopwatch::class => Stopwatch::class, + TokenStorageInterface::class => TokenStorageInterface::class, + TranslatorInterface::class => TranslatorInterface::class, + \RZ\Roadiz\Core\Handlers\HandlerFactoryInterface::class => HandlerFactoryInterface::class, + ]); + } + + /** + * @return Request + */ + protected function getRequest(): Request + { + /** @var RequestStack $requestStack */ + $requestStack = $this->get(RequestStack::class); + $request = $requestStack->getCurrentRequest(); + if (null === $request) { + throw new BadRequestHttpException('Request is not available in this context'); + } + return $request; + } + + /** + * @return Security + */ + protected function getAuthorizationChecker(): Security + { + /** @var Security $security */ # php-stan hint + $security = $this->get(Security::class); + return $security; + } + + /** + * Alias for `$this->container['securityTokenStorage']`. + * + * @return TokenStorageInterface + */ + protected function getTokenStorage(): TokenStorageInterface + { + /** @var TokenStorageInterface $tokenStorage */ # php-stan hint + $tokenStorage = $this->get(TokenStorageInterface::class); + return $tokenStorage; + } + + /** + * Alias for `$this->container['em']`. + * + * @return ObjectManager + */ + protected function em(): ObjectManager + { + return $this->getDoctrine()->getManager(); + } + + /** + * @return TranslatorInterface + */ + protected function getTranslator(): TranslatorInterface + { + /** @var TranslatorInterface $translator */ # php-stan hint + $translator = $this->get(TranslatorInterface::class); + return $translator; + } + + /** + * @return Environment + */ + protected function getTwig(): Environment + { + /** @var Environment $twig */ # php-stan hint + $twig = $this->get(Environment::class); + return $twig; + } + + protected function getStopwatch(): Stopwatch + { + /** @var Stopwatch $stopwatch */ + $stopwatch = $this->get(Stopwatch::class); + return $stopwatch; + } + + protected function getPreviewResolver(): PreviewResolverInterface + { + /** @var PreviewResolverInterface $previewResolver */ + $previewResolver = $this->get(PreviewResolverInterface::class); + return $previewResolver; + } + + /** + * @param object $event + * @return object The passed $event MUST be returned + */ + protected function dispatchEvent($event) + { + /** @var EventDispatcherInterface $eventDispatcher */ # php-stan hint + $eventDispatcher = $this->get('event_dispatcher'); + return $eventDispatcher->dispatch($event); + } + + protected function getSettingsBag(): Settings + { + /** @var Settings $settingsBag */ # php-stan hint + $settingsBag = $this->get(Settings::class); + return $settingsBag; + } + + /** + * @return Packages + * @deprecated + */ + protected function getPackages(): Packages + { + /** @var Packages $packages */ # php-stan hint + $packages = $this->get('assetPackages'); + return $packages; + } + + protected function getHandlerFactory(): HandlerFactoryInterface + { + /** @var HandlerFactoryInterface $handlerFactory */ # php-stan hint + $handlerFactory = $this->get(HandlerFactoryInterface::class); + return $handlerFactory; + } + + protected function getLogger(): LoggerInterface + { + /** @var LoggerInterface $logger */ # php-stan hint + $logger = $this->get(LoggerInterface::class); + return $logger; + } + + /** + * Wrap `$this->get('urlGenerator')->generate` + * + * @param string|NodesSources $route + * @param array $parameters + * @param int $referenceType + * @return string + */ + protected function generateUrl($route, array $parameters = [], int $referenceType = UrlGeneratorInterface::ABSOLUTE_PATH): string + { + if ($route instanceof NodesSources) { + /** @var UrlGeneratorInterface $urlGenerator */ + $urlGenerator = $this->get('urlGenerator'); + return $urlGenerator->generate( + RouteObjectInterface::OBJECT_BASED_ROUTE_NAME, + array_merge($parameters, [RouteObjectInterface::ROUTE_OBJECT => $route]), + $referenceType + ); + } + return parent::generateUrl($route, $parameters, $referenceType); + } + + /** + * @return string + */ + public static function getCalledClass() + { + $className = get_called_class(); + if (strpos($className, "\\") !== 0) { + $className = "\\" . $className; + } + return $className; + } + + /** + * Validate a request against a given ROLE_* and throws + * an AccessDeniedException exception. + * + * @param string $role + * @deprecated Use denyAccessUnlessGranted() method instead + * @throws AccessDeniedException + * @return void + */ + public function validateAccessForRole($role) + { + if (!$this->isGranted($role)) { + throw new AccessDeniedException("You don't have access to this page:" . $role); + } + } + + /** + * Custom route for redirecting routes with a trailing slash. + * + * @param Request $request + * + * @return RedirectResponse + */ + public function removeTrailingSlashAction(Request $request) + { + $pathInfo = $request->getPathInfo(); + $requestUri = $request->getRequestUri(); + + $url = str_replace($pathInfo, rtrim($pathInfo, ' /'), $requestUri); + + return $this->redirect($url, Response::HTTP_MOVED_PERMANENTLY); + } + + /** + * Make translation variable with the good localization. + * + * @param Request $request + * @param string $_locale + * + * @return TranslationInterface + * @throws NoTranslationAvailableException + */ + protected function bindLocaleFromRoute(Request $request, $_locale = null): TranslationInterface + { + /* + * If you use a static route for Home page + * we need to grab manually language. + * + * Get language from static route + */ + $translation = $this->findTranslationForLocale($_locale); + $request->setLocale($translation->getPreferredLocale()); + return $translation; + } + + /** + * @param string|null $_locale + * + * @return TranslationInterface + */ + protected function findTranslationForLocale(string $_locale = null): TranslationInterface + { + if (null === $_locale) { + $defaultTranslation = $this->getDoctrine()->getRepository(Translation::class)->findDefault(); + if (null === $defaultTranslation) { + throw new NoTranslationAvailableException(); + } + return $defaultTranslation; + } + /** @var TranslationRepository $repository */ + $repository = $this->getDoctrine()->getRepository(Translation::class); + + if ($this->getPreviewResolver()->isPreview()) { + $translation = $repository->findOneByLocaleOrOverrideLocale($_locale); + } else { + $translation = $repository->findOneAvailableByLocaleOrOverrideLocale($_locale); + } + + if (null !== $translation) { + return $translation; + } + + throw new NoTranslationAvailableException(); + } + + /** + * Return a Response from a template string with its rendering assignation. + * + * @see http://api.symfony.com/2.6/Symfony/Bundle/FrameworkBundle/Controller/Controller.html#method_render + * + * @param string $view Template file path + * @param array $parameters Twig assignation array + * @param Response|null $response Optional Response object to customize response parameters + * @param string $namespace Twig loader namespace + * + * @return Response + * @throws RuntimeError + */ + public function render(string $view, array $parameters = [], Response $response = null, string $namespace = ''): Response + { + try { + return parent::render($view, $parameters, $response); + } catch (RuntimeError $e) { + if ($e->getPrevious() instanceof \RZ\Roadiz\CoreBundle\Exception\ForceResponseException) { + return $e->getPrevious()->getResponse(); + } else { + throw $e; + } + } + } + + /** + * @param string $view + * @param string $namespace + * @return string + */ + protected function getNamespacedView(string $view, string $namespace = ''): string + { + if ($namespace !== "" && $namespace !== "/") { + return '@' . $namespace . '/' . $view; + } + + return $view; + } + + /** + * @param array $data + * @param int $httpStatus + * @return JsonResponse + */ + public function renderJson(array $data = [], int $httpStatus = JsonResponse::HTTP_OK) + { + return $this->json($data, $httpStatus); + } + + /** + * Throw a NotFoundException if request format is not accepted. + * + * @param Request $request + * @param array $acceptableFormats + * @return void + */ + protected function denyResourceExceptForFormats(Request $request, array $acceptableFormats = ['html']) + { + if (!in_array($request->get('_format', 'html'), $acceptableFormats)) { + throw $this->createNotFoundException(sprintf( + 'Resource not found for %s format', + $request->get('_format', 'html') + )); + } + } + + /** + * Creates and returns a form builder instance. + * + * @param string $name Form name + * @param mixed $data The initial data for the form + * @param array $options Options for the form + * + * @return FormBuilderInterface + */ + protected function createNamedFormBuilder(string $name = 'form', $data = null, array $options = []) + { + /** @var FormFactoryInterface $formFactory */ + $formFactory = $this->get('form.factory'); + return $formFactory->createNamedBuilder($name, FormType::class, $data, $options); + } + + /** + * Creates and returns an EntityListManager instance. + * + * @param mixed $entity Entity class path + * @param array $criteria + * @param array $ordering + * + * @return EntityListManagerInterface + */ + public function createEntityListManager($entity, array $criteria = [], array $ordering = []) + { + return new EntityListManager( + $this->getRequest(), + $this->getDoctrine()->getManager(), + $entity, + $criteria, + $ordering + ); + } + + /** + * Create and return a ContactFormManager to build and send contact + * form by email. + * + * @return ContactFormManager + */ + public function createContactFormManager(): ContactFormManager + { + /** @var ContactFormManager $contactFormManager */ # php-stan hinting + $contactFormManager = $this->get(ContactFormManager::class); + return $contactFormManager; + } + + /** + * Create and return a EmailManager to build and send emails. + * + * @return EmailManager + */ + public function createEmailManager(): EmailManager + { + /** @var EmailManager $emailManager */ # php-stan hinting + $emailManager = $this->get(EmailManager::class); + return $emailManager; + } + + /** + * Get a user from the tokenStorage. + * + * @return UserInterface|object|null + * + * @throws \LogicException If tokenStorage is not available + * + * @see TokenInterface::getUser() + */ + protected function getUser() + { + if (!$this->has('securityTokenStorage')) { + throw new \LogicException('No TokenStorage has been registered in your application.'); + } + + /** @var TokenInterface|null $token */ + $token = $this->getTokenStorage()->getToken(); + if (null === $token) { + return null; + } + + $user = $token->getUser(); + + return \is_object($user) ? $user : null; + } +} diff --git a/lib/RoadizCompatBundle/src/Controller/FrontendController.php b/lib/RoadizCompatBundle/src/Controller/FrontendController.php new file mode 100644 index 00000000..b106f5bd --- /dev/null +++ b/lib/RoadizCompatBundle/src/Controller/FrontendController.php @@ -0,0 +1,433 @@ + + */ + protected static array $specificNodesControllers = [ + 'home', + ]; + + protected ?Node $node = null; + protected ?NodesSources $nodeSource = null; + protected ?TranslationInterface $translation = null; + /** + * @var \Pimple\Container|null + * @deprecated Use a service locator object + */ + protected ?\Pimple\Container $themeContainer = null; + + public static function getSubscribedServices(): array + { + return array_merge(parent::getSubscribedServices(), [ + ThemeResolverInterface::class => ThemeResolverInterface::class + ]); + } + + /** + * @return Node|null + */ + protected function getNode(): ?Node + { + return $this->node; + } + + /** + * @return NodesSources|null + */ + protected function getNodeSource(): ?NodesSources + { + return $this->nodeSource; + } + + /** + * @return TranslationInterface|null + */ + protected function getTranslation(): ?TranslationInterface + { + return $this->translation; + } + + /** + * Default action for any node URL. + * + * @param Request $request + * @param Node|null $node + * @param TranslationInterface|null $translation + * + * @return Response + */ + public function indexAction( + Request $request, + Node $node = null, + TranslationInterface $translation = null + ) { + $this->getStopwatch()->start('handleNodeController'); + $this->node = $node; + $this->translation = $translation; + + // Main node based routing method + return $this->handle($request, $this->node, $this->translation); + } + + /** + * Handle node based routing, returns a Response object + * for a node-based request. + * + * @param Request $request + * @param Node|null $node + * @param TranslationInterface|null $translation + * @return Response + * @throws \ReflectionException + */ + protected function handle( + Request $request, + Node $node = null, + TranslationInterface $translation = null + ) { + $this->getStopwatch()->start('handleNodeController'); + + if ($node !== null) { + $nodeRouteHelper = new NodeRouteHelper( + $node, + $this->getTheme(), + $this->getPreviewResolver(), + $this->getLogger(), + DefaultNodeSourceController::class + ); + $controllerPath = $nodeRouteHelper->getController(); + $method = $nodeRouteHelper->getMethod(); + + if (true !== $nodeRouteHelper->isViewable()) { + $msg = "No front-end controller found for '" . + $node->getNodeName() . + "' node. You need to create a " . $controllerPath . "."; + throw $this->createNotFoundException($msg); + } + + return $this->forward($controllerPath . '::' . $method, [ + 'node' => $node, + 'translation' => $translation + ]); + } + + throw $this->createNotFoundException("No node was found to handle"); + } + + /** + * Default action for default URL (homepage). + * + * @param Request $request + * @param string|null $_locale + * + * @return Response + */ + public function homeAction(Request $request, $_locale = null) + { + /* + * If you use a static route for Home page + * we need to grab manually language. + * + * Get language from static route + */ + $translation = $this->bindLocaleFromRoute($request, $_locale); + + /* + * Grab home flagged node + */ + $node = $this->getHome($translation); + $this->prepareThemeAssignation($node, $translation); + + return $this->render('home.html.twig', $this->assignation); + } + + /** + * Store basic information for your theme from a Node object. + * + * @param Node|null $node + * @param TranslationInterface|null $translation + * + * @return void + */ + protected function prepareThemeAssignation(Node $node = null, TranslationInterface $translation = null) + { + if (null === $this->themeContainer) { + $this->getStopwatch()->start('prepareThemeAssignation'); + $this->storeNodeAndTranslation($node, $translation); + $home = $this->getHome($translation); + if (null !== $home && null !== $translation) { + $this->assignation['home'] = $home; + $this->assignation['homeSource'] = $home->getNodeSourcesByTranslation($translation)->first(); + } + /* + * Use a DI container to delay API requests + */ + $this->themeContainer = new \Pimple\Container(); + + $this->getStopwatch()->start('extendAssignation'); + $this->extendAssignation(); + $this->getStopwatch()->stop('extendAssignation'); + $this->getStopwatch()->stop('prepareThemeAssignation'); + } + } + + /** + * Store current node and translation into controller. + * + * It makes following fields available into template assignation: + * + * * node + * * nodeSource + * * translation + * * pageMeta + * * title + * * description + * * keywords + * + * @param Node|null $node + * @param TranslationInterface|null $translation + * @return void + */ + public function storeNodeAndTranslation(Node $node = null, TranslationInterface $translation = null) + { + $this->node = $node; + $this->translation = $translation; + $this->assignation['translation'] = $this->translation; + $this->getRequest()->attributes->set('translation', $this->translation); + + if (null !== $this->node && null !== $translation) { + $this->getRequest()->attributes->set('node', $this->node); + $this->nodeSource = $this->node->getNodeSourcesByTranslation($translation)->first() ?: null; + $this->assignation['node'] = $this->node; + $this->assignation['nodeSource'] = $this->nodeSource; + } + + $this->assignation['pageMeta'] = $this->getNodeSEO(); + } + + /** + * Get SEO information for current node. + * + * This method must return a 3-fields array with: + * + * * `title` + * * `description` + * * `keywords` + * + * @param NodesSources|null $fallbackNodeSource + * + * @return array + */ + protected function getNodeSEO(NodesSources $fallbackNodeSource = null) + { + if (null !== $this->nodeSource) { + /** @var NodesSourcesHandler $nodesSourcesHandler */ + $nodesSourcesHandler = $this->getHandlerFactory()->getHandler($this->nodeSource); + return $nodesSourcesHandler->getSEO(); + } + + if (null !== $fallbackNodeSource) { + /** @var NodesSourcesHandler $nodesSourcesHandler */ + $nodesSourcesHandler = $this->getHandlerFactory()->getHandler($fallbackNodeSource); + return $nodesSourcesHandler->getSEO(); + } + + return [ + 'title' => '', + 'description' => '', + 'keywords' => '', + ]; + } + + /** + * Extends theme assignation with custom data. + * + * Override this method in your theme to add your own service + * and data. + * + * @return void + */ + protected function extendAssignation() + { + } + + /** + * Add a default translation locale for static routes and + * node SEO data. + * + * * [parent assignations…] + * * **_default_locale** + * * meta + * * siteName + * * siteCopyright + * * siteDescription + * + * @return $this + */ + public function prepareBaseAssignation(): static + { + parent::prepareBaseAssignation(); + + /** @var TranslationInterface $translation */ + $translation = $this->get('defaultTranslation'); + $this->assignation['_default_locale'] = $translation->getLocale(); + + return $this; + } + + /** + * {@inheritdoc} + */ + public function maintenanceAction(Request $request): Response + { + $translation = $this->bindLocaleFromRoute($request, $request->getLocale()); + $this->prepareThemeAssignation(null, $translation); + + return new Response( + $this->renderView('maintenance.html.twig', $this->assignation), + Response::HTTP_SERVICE_UNAVAILABLE, + ['content-type' => 'text/html'] + ); + } + + /** + * Store basic information for your theme from a NodesSources object. + * + * @param NodesSources|null $nodeSource + * @param TranslationInterface|null $translation + * + * @return void + */ + protected function prepareNodeSourceAssignation( + NodesSources $nodeSource = null, + TranslationInterface $translation = null + ): void { + if (null === $this->themeContainer) { + $this->storeNodeSourceAndTranslation($nodeSource, $translation); + /** @deprecated Should not fetch home at each request */ + $this->assignation['home'] = $this->getHome($translation); + /* + * Use a DI container to delay API requests + */ + $this->themeContainer = new \Pimple\Container(); + $this->extendAssignation(); + } + } + + /** + * Store current nodeSource and translation into controller. + * + * It makes following fields available into template assignation: + * + * * node + * * nodeSource + * * translation + * * pageMeta + * * title + * * description + * * keywords + * + * @param NodesSources|null $nodeSource + * @param TranslationInterface|null $translation + * @return void + */ + public function storeNodeSourceAndTranslation( + NodesSources $nodeSource = null, + TranslationInterface $translation = null + ): void { + $this->nodeSource = $nodeSource; + + if (null !== $this->nodeSource) { + $this->node = $this->nodeSource->getNode(); + $this->translation = $this->nodeSource->getTranslation(); + + $this->getRequest()->attributes->set('translation', $this->translation); + $this->getRequest()->attributes->set('node', $this->node); + + $this->assignation['translation'] = $this->translation; + $this->assignation['node'] = $this->node; + $this->assignation['nodeSource'] = $this->nodeSource; + } else { + $this->translation = $translation; + $this->assignation['translation'] = $this->translation; + $this->getRequest()->attributes->set('translation', $this->translation); + } + + $this->assignation['pageMeta'] = $this->getNodeSEO(); + } + + /** + * Deny access (404) node-source access if its publication date is in the future. + * + * @throws \Exception + * @return void + */ + protected function denyAccessUnlessPublished(): void + { + if (null !== $this->nodeSource) { + if ( + $this->nodeSource->getPublishedAt() > new \DateTime() && + !$this->getPreviewResolver()->isPreview() + ) { + throw $this->createNotFoundException(); + } + } + } + + /** + * @inheritDoc + */ + public function createEntityListManager($entity, array $criteria = [], array $ordering = []) + { + return parent::createEntityListManager($entity, $criteria, $ordering) + ->setAllowRequestSearching(false) + ->setAllowRequestSorting(false); + } +} diff --git a/lib/RoadizCompatBundle/src/DependencyInjection/Compiler/ThemesTranslatorPathsCompilerPass.php b/lib/RoadizCompatBundle/src/DependencyInjection/Compiler/ThemesTranslatorPathsCompilerPass.php new file mode 100644 index 00000000..41f39570 --- /dev/null +++ b/lib/RoadizCompatBundle/src/DependencyInjection/Compiler/ThemesTranslatorPathsCompilerPass.php @@ -0,0 +1,87 @@ +hasDefinition('translator.default')) { + $this->registerThemeTranslatorResources($container); + } + } + + private function registerThemeTranslatorResources(ContainerBuilder $container): void + { + /** @var string $projectDir */ + $projectDir = $container->getParameter('kernel.project_dir'); + /** @var \Iterator|array $themesConfig */ + $themesConfig = $container->getParameter('roadiz_compat.themes'); + $translator = $container->findDefinition('translator.default'); + $options = [ + 'resource_files' => [], + 'scanned_directories' => [], + 'cache_vary' => [ + 'scanned_directories' => [], + ], + ]; + + foreach ($themesConfig as $themeConfig) { + /** @var class-string $className */ + $className = $themeConfig['classname']; + + // add translations paths + $translationFolder = $className::getTranslationsFolder(); + if (file_exists($translationFolder)) { + $files = []; + $finder = Finder::create() + ->followLinks() + ->files() + ->filter(function (\SplFileInfo $file) { + return 2 <= substr_count($file->getBasename(), '.') && + preg_match('/\.\w+$/', $file->getBasename()); + }) + ->in($translationFolder) + ->sortByName() + ; + foreach ($finder as $file) { + $fileNameParts = explode('.', basename((string) $file)); + $locale = $fileNameParts[\count($fileNameParts) - 2]; + if (!isset($files[$locale])) { + $files[$locale] = []; + } + + $files[$locale][] = (string) $file; + } + $options = array_merge_recursive( + $options, + [ + 'resource_files' => $files, + 'scanned_directories' => $scannedDirectories = [$translationFolder], + 'cache_vary' => [ + 'scanned_directories' => array_map(static function (string $dir) use ($projectDir): string { + return str_starts_with($dir, $projectDir . '/') ? substr($dir, 1 + \strlen($projectDir)) : $dir; + }, $scannedDirectories), + ], + ] + ); + } + } + $options = array_merge_recursive( + $translator->getArgument(4), + $options + ); + + $translator->replaceArgument(4, $options); + } +} diff --git a/lib/RoadizCompatBundle/src/DependencyInjection/Configuration.php b/lib/RoadizCompatBundle/src/DependencyInjection/Configuration.php new file mode 100644 index 00000000..a22d94ff --- /dev/null +++ b/lib/RoadizCompatBundle/src/DependencyInjection/Configuration.php @@ -0,0 +1,55 @@ +getRootNode(); + $root->append($this->addThemesNode()); + return $builder; + } + + /** + * @return ArrayNodeDefinition|NodeDefinition + */ + protected function addThemesNode() + { + $builder = new TreeBuilder('themes'); + $node = $builder->getRootNode(); + + $node + ->defaultValue([]) + ->prototype('array') + ->children() + ->scalarNode('classname') + ->info('Full qualified theme class (this must start with \ character and ends with App suffix)') + ->isRequired() + ->validate() + ->ifTrue(function (string $s) { + return preg_match('/^\\\[a-zA-Z\\\]+App$/', trim($s)) !== 1 || !class_exists($s); + }) + ->thenInvalid('Theme class does not exist or classname is invalid: must start with \ character and ends with App suffix.') + ->end() + ->end() + ->scalarNode('hostname') + ->defaultValue('*') + ->end() + ->scalarNode('routePrefix') + ->defaultValue('') + ->end() + ->end() + ->end(); + + return $node; + } +} diff --git a/lib/RoadizCompatBundle/src/DependencyInjection/RoadizCompatExtension.php b/lib/RoadizCompatBundle/src/DependencyInjection/RoadizCompatExtension.php new file mode 100644 index 00000000..b6490227 --- /dev/null +++ b/lib/RoadizCompatBundle/src/DependencyInjection/RoadizCompatExtension.php @@ -0,0 +1,103 @@ +load('services.yaml'); + + $configuration = new Configuration(); + $config = $this->processConfiguration($configuration, $configs); + + $container->setParameter('roadiz_compat.themes', $config['themes']); + $container->setDefinition( + 'defaultTranslation', + (new Definition()) + ->setClass(Translation::class) + ->setFactory([new Reference(TranslationRepository::class), 'findDefault']) + ->setShared(true) + ->setPublic(true) + ->setDeprecated('roadiz_compat', '2.0.0', '%service_id% service is deprecated, use TranslationRepository instead') + ); + + $this->registerThemes($config, $container); + } + + private function registerThemes(array $config, ContainerBuilder $container): void + { + $frontendThemes = []; + + foreach ($config['themes'] as $index => $themeConfig) { + $themeSlug = (new AsciiSlugger())->slug($themeConfig['classname'], '_'); + $serviceId = 'roadiz_compat.themes.' . $themeSlug; + /** @var class-string $className */ + $className = $themeConfig['classname']; + $themeDir = $className::getThemeDir(); + $container->setDefinition( + $serviceId, + (new Definition()) + ->setClass(Theme::class) + ->setPublic(true) + ->addMethodCall('setId', [$index]) + ->addMethodCall('setAvailable', [true]) + ->addMethodCall('setClassName', [$className]) + ->addMethodCall('setHostname', [$themeConfig['hostname']]) + ->addMethodCall('setRoutePrefix', [$themeConfig['routePrefix']]) + ->addMethodCall('setBackendTheme', [false]) + ->addMethodCall('setStaticTheme', [false]) + ->addTag('roadiz_compat.theme') + ); + $frontendThemes[] = new Reference($serviceId); + + // Register asset packages + $container->setDefinition( + 'roadiz_compat.assets._package.' . $themeSlug, + (new Definition()) + ->setClass(PathPackage::class) + ->setArguments([ + 'themes/' . $themeDir . '/static', + new Reference('assets.empty_version_strategy'), + new Reference('assets.context') + ]) + ->addTag('assets.package', [ + 'package' => $themeDir + ]) + ); + + // Add Twig paths + $container->getDefinition('roadiz_compat.twig_loader') + ->addMethodCall('prependPath', [ + $className::getViewsFolder() + ]) + ->addMethodCall('prependPath', [ + $className::getViewsFolder(), $themeDir + ]); + } + + if ($container->hasDefinition(StaticThemeResolver::class)) { + $container->getDefinition(StaticThemeResolver::class)->setArgument('$themes', $frontendThemes); + } + } +} diff --git a/lib/RoadizCompatBundle/src/EventSubscriber/ControllerEventSubscriber.php b/lib/RoadizCompatBundle/src/EventSubscriber/ControllerEventSubscriber.php new file mode 100644 index 00000000..1f2b2bdd --- /dev/null +++ b/lib/RoadizCompatBundle/src/EventSubscriber/ControllerEventSubscriber.php @@ -0,0 +1,32 @@ + ['onKernelController', -128], + ]; + } + + public function onKernelController(ControllerEvent $event): void + { + $controller = $event->getController(); + + if (\is_array($controller) && $controller[0] instanceof AppController) { + $controller[0]->prepareBaseAssignation(); + } + } +} diff --git a/lib/RoadizCompatBundle/src/EventSubscriber/ExceptionSubscriber.php b/lib/RoadizCompatBundle/src/EventSubscriber/ExceptionSubscriber.php new file mode 100644 index 00000000..a16056d1 --- /dev/null +++ b/lib/RoadizCompatBundle/src/EventSubscriber/ExceptionSubscriber.php @@ -0,0 +1,210 @@ +debug = $debug; + $this->viewer = $viewer; + $this->themeResolver = $themeResolver; + $this->serviceLocator = $serviceLocator; + $this->logger = $logger; + } + + /** + * @return array + */ + public static function getSubscribedEvents(): array + { + /* + * Roadiz exception handling must be triggered AFTER firewall exceptions + */ + return [ + KernelEvents::EXCEPTION => ['onKernelException', -1], + ]; + } + + /** + * @param ExceptionEvent $event + * @throws \Psr\Container\ContainerExceptionInterface + * @throws \Psr\Container\NotFoundExceptionInterface + */ + public function onKernelException(ExceptionEvent $event): void + { + if ($this->debug) { + return; + } + + // You get the exception object from the received event + $exception = $event->getThrowable(); + + /* + * Get previous exception if thrown in Twig execution context. + */ + if ($exception instanceof RuntimeError && null !== $exception->getPrevious()) { + $exception = $exception->getPrevious(); + } + + if (!$this->viewer->isFormatJson($event->getRequest())) { + if ($exception instanceof MaintenanceModeException) { + /* + * Themed exception pages… + */ + $ctrl = $exception->getController(); + if ( + null !== $ctrl && + method_exists($ctrl, 'maintenanceAction') + ) { + try { + /** @var Response $response */ + $response = $ctrl->maintenanceAction($event->getRequest()); + // Set http code according to status + $response->setStatusCode($this->viewer->getHttpStatusCode($exception)); + $event->setResponse($response); + return; + } catch (LoaderError $error) { + // Twig template does not exist + } + } + } + if (null !== $theme = $this->isNotFoundExceptionWithTheme($event)) { + $event->setResponse($this->createThemeNotFoundResponse($theme, $exception, $event)); + } + } + } + + /** + * Create an emergency response to be sent instead of error logs. + * + * @param \Exception|\TypeError $e + * @param Request $request + * + * @return Response + */ + protected function getEmergencyResponse($e, Request $request): Response + { + /* + * Log error before displaying a fallback page. + */ + $class = get_class($e); + /* + * Do not flood logs with not-found errors + */ + if (!($e instanceof NotFoundHttpException) && !($e instanceof ResourceNotFoundException)) { + if ($e instanceof HttpExceptionInterface) { + // If HTTP exception do not log to critical + $this->logger->notice($e->getMessage(), [ + 'trace' => $e->getTraceAsString(), + 'exception' => $class, + ]); + } else { + $this->logger->emergency($e->getMessage(), [ + 'trace' => $e->getTraceAsString(), + 'exception' => $class, + ]); + } + } + + return $this->viewer->getResponse($e, $request, $this->debug); + } + + /** + * @param ExceptionEvent $event + * @return null|Theme + */ + protected function isNotFoundExceptionWithTheme(ExceptionEvent $event): ?Theme + { + $exception = $event->getThrowable(); + $request = $event->getRequest(); + + if ( + $exception instanceof ResourceNotFoundException || + $exception instanceof NotFoundHttpException || + ( + null !== $exception->getPrevious() && + ( + $exception->getPrevious() instanceof ResourceNotFoundException || + $exception->getPrevious() instanceof NotFoundHttpException + ) + ) + ) { + if (null !== $theme = $this->themeResolver->findTheme($request->getHost())) { + /* + * 404 page + */ + $request->attributes->set('theme', $theme); + + return $theme; + } + } + + return null; + } + + /** + * @param Theme $theme + * @param \Throwable $exception + * @param ExceptionEvent $event + * + * @return Response + * @throws LoaderError + * @throws RuntimeError + * @throws \Psr\Container\ContainerExceptionInterface + * @throws \Psr\Container\NotFoundExceptionInterface + * @throws \Throwable + * @throws \Twig\Error\SyntaxError + */ + protected function createThemeNotFoundResponse(Theme $theme, \Throwable $exception, ExceptionEvent $event): Response + { + $ctrlClass = $theme->getClassName(); + $controller = new $ctrlClass(); + $serviceId = get_class($controller); + + if ($this->serviceLocator->has($serviceId)) { + $controller = $this->serviceLocator->get($serviceId); + } + if ($controller instanceof AppController) { + return $controller + ->prepareBaseAssignation() + ->throw404($exception->getMessage()); + } + + throw $exception; + } +} diff --git a/lib/RoadizCompatBundle/src/EventSubscriber/MaintenanceModeSubscriber.php b/lib/RoadizCompatBundle/src/EventSubscriber/MaintenanceModeSubscriber.php new file mode 100644 index 00000000..9ab39741 --- /dev/null +++ b/lib/RoadizCompatBundle/src/EventSubscriber/MaintenanceModeSubscriber.php @@ -0,0 +1,147 @@ +settings = $settings; + $this->security = $security; + $this->themeResolver = $themeResolver; + $this->serviceLocator = $serviceLocator; + } + + /** + * @return array + */ + private function getAuthorizedRoutes() + { + return [ + 'loginPage', + 'loginRequestPage', + 'loginRequestConfirmPage', + 'loginResetConfirmPage', + 'loginResetPage', + 'loginFailedPage', + 'loginCheckPage', + 'logoutPage', + 'FontFile', + 'FontFaceCSS', + 'loginImagePage', + 'interventionRequestProcess', + '_profiler_home', + '_profiler_search', + '_profiler_search_bar', + '_profiler_phpinfo', + '_profiler_search_results', + '_profiler_open_file', + '_profiler', + '_profiler_router', + '_profiler_exception', + '_profiler_exception_css', + '_wdt', + ]; + } + + /** + * @return array + */ + public static function getSubscribedEvents(): array + { + return [ + KernelEvents::REQUEST => ['onRequest'], + ]; + } + + /** + * @param RequestEvent $event + * @throws MaintenanceModeException + */ + public function onRequest(RequestEvent $event): void + { + if ($event->isMainRequest()) { + if (\in_array($event->getRequest()->get('_route'), $this->getAuthorizedRoutes())) { + return; + } + + $maintenanceMode = (bool) $this->settings->get('maintenance_mode', false); + if ( + $maintenanceMode === true && + !$this->security->isGranted('ROLE_BACKEND_USER') + ) { + $theme = $this->themeResolver->findTheme(null); + if (null !== $theme) { + throw new MaintenanceModeException($this->getControllerForTheme($theme, $event->getRequest())); + } + throw new MaintenanceModeException(); + } + } + } + + /** + * @param Theme $theme + * @param Request $request + * + * @return AbstractController + */ + private function getControllerForTheme(Theme $theme, Request $request): AbstractController + { + $ctrlClass = $theme->getClassName(); + $controller = new $ctrlClass(); + $serviceId = get_class($controller); + + if ($this->serviceLocator->has($serviceId)) { + $controller = $this->serviceLocator->get($serviceId); + } + + if (!$controller instanceof AbstractController) { + throw new \RuntimeException(sprintf( + 'Theme controller %s must extend %s class', + $ctrlClass, + AbstractController::class + )); + } + + if ($controller instanceof AppController) { + $controller->prepareBaseAssignation(); + // No node controller matching in install mode + $request->attributes->set('theme', $controller->getTheme()); + } + + /* + * Set request locale if _locale param + * is present in Route. + */ + $routeParams = $request->get('_route_params'); + if (!empty($routeParams["_locale"])) { + $request->setLocale($routeParams["_locale"]); + } + + return $controller; + } +} diff --git a/lib/RoadizCompatBundle/src/RoadizCompatBundle.php b/lib/RoadizCompatBundle/src/RoadizCompatBundle.php new file mode 100644 index 00000000..3e3751cd --- /dev/null +++ b/lib/RoadizCompatBundle/src/RoadizCompatBundle.php @@ -0,0 +1,24 @@ +addCompilerPass(new ThemesTranslatorPathsCompilerPass()); + } +} diff --git a/lib/RoadizCompatBundle/src/Routing/ThemeAwareNodeRouter.php b/lib/RoadizCompatBundle/src/Routing/ThemeAwareNodeRouter.php new file mode 100644 index 00000000..906feb91 --- /dev/null +++ b/lib/RoadizCompatBundle/src/Routing/ThemeAwareNodeRouter.php @@ -0,0 +1,71 @@ +themeResolver = $themeResolver; + $this->innerRouter = $innerRouter; + } + + public function setContext(RequestContext $context): void + { + $this->innerRouter->setContext($context); + } + + public function getContext(): RequestContext + { + return $this->innerRouter->getContext(); + } + + public function matchRequest(Request $request): array + { + return $this->innerRouter->matchRequest($request); + } + + public function getRouteCollection(): RouteCollection + { + return $this->innerRouter->getRouteCollection(); + } + + public function generate(string $name, array $parameters = [], int $referenceType = self::ABSOLUTE_PATH): string + { + $this->innerRouter->setTheme($this->themeResolver->findTheme($this->getContext()->getHost())); + return $this->innerRouter->generate($name, $parameters, $referenceType); + } + + public function match(string $pathinfo): array + { + return $this->innerRouter->match($pathinfo); + } + + public function supports($name): bool + { + return $this->innerRouter->supports($name); + } + + public function getRouteDebugMessage($name, array $parameters = []): string + { + return $this->innerRouter->getRouteDebugMessage($name, $parameters); + } +} diff --git a/lib/RoadizCompatBundle/src/Routing/ThemeAwareNodeUrlMatcher.php b/lib/RoadizCompatBundle/src/Routing/ThemeAwareNodeUrlMatcher.php new file mode 100644 index 00000000..78c306ec --- /dev/null +++ b/lib/RoadizCompatBundle/src/Routing/ThemeAwareNodeUrlMatcher.php @@ -0,0 +1,76 @@ +themeResolver = $themeResolver; + $this->innerMatcher = $innerMatcher; + } + + /** + * {@inheritdoc} + */ + public function match(string $pathinfo): array + { + $decodedUrl = rawurldecode($pathinfo); + /* + * Try nodes routes + */ + return $this->matchNode( + $decodedUrl, + $this->themeResolver->findTheme($this->getContext()->getHost()) + ); + } + + public function setContext(RequestContext $context): void + { + $this->innerMatcher->setContext($context); + } + + public function getContext(): RequestContext + { + return $this->innerMatcher->getContext(); + } + + public function matchRequest(Request $request): array + { + return $this->match($request->getPathInfo()); + } + + public function getSupportedFormatExtensions(): array + { + return $this->innerMatcher->getSupportedFormatExtensions(); + } + + public function getDefaultSupportedFormatExtension(): string + { + return $this->innerMatcher->getDefaultSupportedFormatExtension(); + } + + public function matchNode(string $decodedUrl, ?Theme $theme): array + { + return $this->innerMatcher->matchNode( + $decodedUrl, + $theme + ); + } +} diff --git a/lib/RoadizCompatBundle/src/Routing/ThemeRoutesLoader.php b/lib/RoadizCompatBundle/src/Routing/ThemeRoutesLoader.php new file mode 100644 index 00000000..e9c2bddf --- /dev/null +++ b/lib/RoadizCompatBundle/src/Routing/ThemeRoutesLoader.php @@ -0,0 +1,75 @@ +themeResolver = $themeResolver; + } + + /** + * @param mixed $resource + * @param string|null $type + * @return RouteCollection + */ + public function load($resource, string $type = null): RouteCollection + { + if (true === $this->isLoaded) { + throw new \RuntimeException('Do not add the "extra" loader twice'); + } + + $routeCollection = new RouteCollection(); + $frontendThemes = $this->themeResolver->getFrontendThemes(); + foreach ($frontendThemes as $theme) { + /** @var class-string $feClass */ + $feClass = $theme->getClassName(); + /** @var RouteCollection $feCollection */ + $feCollection = call_user_func([$feClass, 'getRoutes']); + /** @var RouteCollection $feBackendCollection */ + $feBackendCollection = call_user_func([$feClass, 'getBackendRoutes']); + + if ($feCollection !== null) { + // set host pattern if defined + if ($theme->getHostname() != '*' && $theme->getHostname() != '') { + $feCollection->setHost($theme->getHostname()); + } + /* + * Add a global prefix on theme static routes + */ + if ($theme->getRoutePrefix() != '') { + $feCollection->addPrefix($theme->getRoutePrefix()); + } + $routeCollection->addCollection($feCollection); + } + + if ($feBackendCollection !== null) { + /* + * Do not prefix or hostname admin routes. + */ + $routeCollection->addCollection($feBackendCollection); + } + } + + $this->isLoaded = true; + + return $routeCollection; + } + + public function supports($resource, string $type = null): bool + { + return 'themes' === $type; + } +} diff --git a/lib/RoadizCompatBundle/src/Theme/StaticThemeResolver.php b/lib/RoadizCompatBundle/src/Theme/StaticThemeResolver.php new file mode 100644 index 00000000..49aeebaf --- /dev/null +++ b/lib/RoadizCompatBundle/src/Theme/StaticThemeResolver.php @@ -0,0 +1,146 @@ + + */ + protected array $themes; + protected Stopwatch $stopwatch; + protected bool $installMode = false; + + /** + * @param array $themes + * @param Stopwatch $stopwatch + * @param bool $installMode + */ + public function __construct(array $themes, Stopwatch $stopwatch, bool $installMode = false) + { + $this->stopwatch = $stopwatch; + $this->installMode = $installMode; + $this->themes = $themes; + usort($this->themes, [static::class, 'compareThemePriority']); + } + + /** + * @inheritDoc + */ + public function getBackendTheme(): Theme + { + $theme = new Theme(); + $theme->setAvailable(true); + $theme->setClassName($this->getBackendClassName()); + $theme->setBackendTheme(true); + return $theme; + } + + /** + * @return class-string + */ + public function getBackendClassName(): string + { + /** @var class-string $className */ # php-stan hint + $className = '\\Themes\\Rozier\\RozierApp'; + return $className; + } + + /** + * @inheritDoc + */ + public function findTheme(string $host = null): ?Theme + { + $default = null; + /* + * Search theme by beginning at the start of the array. + * Getting high priority theme at last + */ + $searchThemes = $this->getFrontendThemes(); + + foreach ($searchThemes as $theme) { + if ($theme->getHostname() === $host) { + return $theme; + } elseif ($theme->getHostname() === '*') { + // Getting high priority theme at last option + $default = $theme; + } + } + return $default; + } + + /** + * @inheritDoc + */ + public function findThemeByClass(string $classname): ?Theme + { + foreach ($this->getFrontendThemes() as $theme) { + if (ltrim($theme->getClassName(), '\\') === ltrim($classname, '\\')) { + return $theme; + } + } + return null; + } + + /** + * @inheritDoc + */ + public function findAll(): array + { + $backendThemes = []; + if (class_exists($this->getBackendClassName())) { + $backendThemes = [ + $this->getBackendTheme(), + ]; + } + return array_merge($backendThemes, $this->getFrontendThemes()); + } + + /** + * @inheritDoc + */ + public function findById($id): ?Theme + { + if (isset($this->getFrontendThemes()[$id])) { + return $this->getFrontendThemes()[$id]; + } + return null; + } + + /** + * @inheritDoc + */ + public function getFrontendThemes(): array + { + return $this->themes; + } + + /** + * @param Theme $themeA + * @param Theme $themeB + * + * @return int + */ + public static function compareThemePriority(Theme $themeA, Theme $themeB): int + { + /** @var class-string $classA */ + $classA = $themeA->getClassName(); + /** @var class-string $classB */ + $classB = $themeB->getClassName(); + + if (call_user_func([$classA, 'getPriority']) === call_user_func([$classB, 'getPriority'])) { + return 0; + } + if (call_user_func([$classA, 'getPriority']) > call_user_func([$classB, 'getPriority'])) { + return 1; + } else { + return -1; + } + } +} diff --git a/lib/RoadizCompatBundle/src/Theme/ThemeGenerator.php b/lib/RoadizCompatBundle/src/Theme/ThemeGenerator.php new file mode 100644 index 00000000..acbb7303 --- /dev/null +++ b/lib/RoadizCompatBundle/src/Theme/ThemeGenerator.php @@ -0,0 +1,305 @@ +filesystem = new Filesystem(); + $this->projectDir = $projectDir; + $this->publicDir = $publicDir; + $this->cacheDir = $cacheDir; + $this->logger = $logger; + } + + /** + * @param ThemeInfo $themeInfo + * @param string $branch + * + * @return $this + */ + public function downloadTheme(ThemeInfo $themeInfo, string $branch = 'master'): ThemeGenerator + { + if (!$themeInfo->exists()) { + /* + * Clone BaseTheme + */ + $process = new Process( + ['git', 'clone', '-b', $branch, static::REPOSITORY, $themeInfo->getThemePath()] + ); + $process->run(); + $this->logger->info('BaseTheme cloned into ' . $themeInfo->getThemePath()); + } else { + $this->logger->info($themeInfo->getClassname() . ' already exists.'); + } + + return $this; + } + + /** + * @param ThemeInfo $themeInfo + * + * @return $this + */ + public function renameTheme(ThemeInfo $themeInfo): ThemeGenerator + { + if (!$themeInfo->exists()) { + throw new FileException($themeInfo->getThemePath() . ' theme does not exist.'); + } + if ($themeInfo->isProtected()) { + throw new \InvalidArgumentException( + $themeInfo->getThemeName() . ' is protected and cannot renamed.' + ); + } + /* + * Remove existing Git history. + */ + $this->filesystem->remove($themeInfo->getThemePath() . '/.git'); + $this->logger->info('Remove Git history.'); + + /* + * Rename main theme class. + */ + $mainClassFile = $themeInfo->getThemePath() . '/' . $themeInfo->getThemeName() . 'App.php'; + if (!$this->filesystem->exists($mainClassFile)) { + $this->filesystem->rename( + $themeInfo->getThemePath() . '/BaseThemeApp.php', + $mainClassFile + ); + /* + * Force Zend OPcache to reset file + */ + if (function_exists('opcache_invalidate')) { + opcache_invalidate($mainClassFile, true); + } + if (function_exists('apcu_clear_cache')) { + apcu_clear_cache(); + } + $this->logger->info('Rename main theme class.'); + } + + $serviceProviderFile = $themeInfo->getThemePath() . + '/Services/' . $themeInfo->getThemeName() . 'ServiceProvider.php'; + if (!$this->filesystem->exists($serviceProviderFile)) { + $this->filesystem->rename( + $themeInfo->getThemePath() . '/Services/BaseThemeServiceProvider.php', + $serviceProviderFile + ); + /* + * Force Zend OPcache to reset file + */ + if (function_exists('opcache_invalidate')) { + opcache_invalidate($serviceProviderFile, true); + } + if (function_exists('apcu_clear_cache')) { + apcu_clear_cache(); + } + $this->logger->info('Rename theme service provider class.'); + } + + /* + * Rename every occurrence of BaseTheme in your theme. + */ + $processes = new ArrayCollection(); + $processes->add(new Process( + [ + 'find', $themeInfo->getThemePath(), '-type', 'f', '-exec', 'sed', '-i.bak', + '-e', 's/BaseTheme/' . $themeInfo->getThemeName() . '/g', '{}', ';', + ], + null, + ['LC_ALL' => 'C'] + )); + $processes->add(new Process( + [ + 'find', $themeInfo->getThemePath(), '-type', 'f', '-exec', 'sed', '-i.bak', + '-e', 's/Base theme/' . $themeInfo->getName() . ' theme/g', '{}', ';', + ], + null, + ['LC_ALL' => 'C'] + )); + $processes->add(new Process( + [ + 'find', $themeInfo->getThemePath() . '/static', '-type', 'f', '-exec', 'sed', '-i.bak', + '-e', 's/Base/' . $themeInfo->getName() . '/g', '{}', ';', + ], + null, + ['LC_ALL' => 'C'] + )); + $processes->add(new Process( + [ + 'find', $themeInfo->getThemePath() , '-type', 'f', '-name', '*.bak', '-exec', 'rm', '-f', '{}', ';', + ], + null, + ['LC_ALL' => 'C'] + )); + $this->logger->info('Rename every occurrences of BaseTheme in your theme.'); + /** @var Process $process */ + foreach ($processes as $process) { + $process->run(); + } + + $cacheClearer = new OPCacheClearer(); + $cacheClearer->clear(); + + return $this; + } + + /** + * @param ThemeInfo $themeInfo + * @param string $expectedMethod + * + * @return string|null + */ + public function installThemeAssets(ThemeInfo $themeInfo, string $expectedMethod): ?string + { + if ($themeInfo->exists()) { + $publicThemeDir = $this->publicDir . '/themes/' . $themeInfo->getThemeName(); + if ($publicThemeDir !== $themeInfo->getThemePath()) { + $targetDir = $publicThemeDir . '/static'; + $originDir = $themeInfo->getThemePath() . '/static'; + + $this->filesystem->remove($publicThemeDir); + $this->filesystem->mkdir($publicThemeDir); + + if (static::METHOD_RELATIVE_SYMLINK === $expectedMethod) { + return $this->relativeSymlinkWithFallback($originDir, $targetDir); + } elseif (static::METHOD_ABSOLUTE_SYMLINK === $expectedMethod) { + return $this->absoluteSymlinkWithFallback($originDir, $targetDir); + } else { + return $this->hardCopy($originDir, $targetDir); + } + } else { + $this->logger->info($themeInfo->getThemeName() . ' assets are already public.'); + } + } + return null; + } + + /** + * Try to create relative symlink. + * + * Falling back to absolute symlink and finally hard copy. + * + * @param string $originDir + * @param string $targetDir + * @return string + */ + private function relativeSymlinkWithFallback(string $originDir, string $targetDir): string + { + try { + $this->symlink($originDir, $targetDir, true); + $method = self::METHOD_RELATIVE_SYMLINK; + } catch (IOException $e) { + $method = $this->absoluteSymlinkWithFallback($originDir, $targetDir); + } + return $method; + } + + /** + * Try to create absolute symlink. + * + * Falling back to hard copy. + * + * @param string $originDir + * @param string $targetDir + * @return string + */ + private function absoluteSymlinkWithFallback(string $originDir, string $targetDir): string + { + try { + $this->symlink($originDir, $targetDir); + $method = self::METHOD_ABSOLUTE_SYMLINK; + } catch (IOException $e) { + // fall back to copy + $method = $this->hardCopy($originDir, $targetDir); + } + return $method; + } + + /** + * Creates symbolic link. + * + * @param string $originDir + * @param string $targetDir + * @param bool $relative + */ + private function symlink(string $originDir, string $targetDir, bool $relative = false): void + { + if ($relative) { + $this->filesystem->mkdir(dirname($targetDir)); + $realTargetParentDir = realpath(dirname($targetDir)); + if (false === $realTargetParentDir) { + throw new IOException( + sprintf('Cannot resolve realpath for "%s" dirname.', $targetDir), + ); + } + $originDir = $this->filesystem->makePathRelative($originDir, $realTargetParentDir); + } + $this->filesystem->symlink($originDir, $targetDir); + if (!file_exists($targetDir)) { + throw new IOException( + sprintf('Symbolic link "%s" was created but appears to be broken.', $targetDir), + 0, + null, + $targetDir + ); + } + } + + /** + * Copies origin to target. + * + * @param string $originDir + * @param string $targetDir + * @return string + */ + private function hardCopy(string $originDir, string $targetDir): string + { + try { + $this->filesystem->mkdir($targetDir, 0777); + // We use a custom iterator to ignore VCS files + $this->filesystem->mirror( + $originDir, + $targetDir, + Finder::create()->ignoreDotFiles(false)->in($originDir) + ); + } catch (IOException $exception) { + // Do nothing + } + + return static::METHOD_COPY; + } +} diff --git a/lib/RoadizCompatBundle/src/Theme/ThemeInfo.php b/lib/RoadizCompatBundle/src/Theme/ThemeInfo.php new file mode 100644 index 00000000..a86e5bb2 --- /dev/null +++ b/lib/RoadizCompatBundle/src/Theme/ThemeInfo.php @@ -0,0 +1,265 @@ +filesystem = new Filesystem(); + $this->projectDir = $projectDir; + + if (class_exists($name)) { + /* + * If name is a FQN classname + */ + $this->classname = $this->validateClassname($name); + $this->name = $this->extractNameFromClassname($this->classname); + $this->themeName = $this->getThemeNameFromName(); + } else { + $this->name = $this->validateName($name); + $this->themeName = $this->getThemeNameFromName(); + } + } + + public function isProtected(): bool + { + return in_array($this->getThemeName(), self::$protectedThemeNames) && $this->getThemeName() !== 'Rozier'; + } + + /** + * @param string $themeName + * + * @return class-string + * @throws ThemeClassNotValidException + */ + protected function guessClassnameFromThemeName(string $themeName): string + { + switch ($themeName) { + case 'RozierApp': + case 'RozierTheme': + case 'Rozier': + $className = '\\Themes\\Rozier\\RozierApp'; + break; + default: + $className = '\\Themes\\' . $themeName . '\\' . $themeName . 'App'; + break; + } + + if (class_exists($className)) { + return $className; + } else { + throw new ThemeClassNotValidException(sprintf( + '“%s” theme is not available in your project.', + $className + )); + } + } + + /** + * @param class-string $classname + * + * @return string + */ + protected function extractNameFromClassname(string $classname): string + { + $shortName = $this->getThemeReflectionClass($classname)->getShortName(); + + return preg_replace('#(?:Theme)?(?:App)?$#', '', $shortName); + } + + /** + * @param class-string $classname + * @return class-string + */ + protected function validateClassname(string $classname): string + { + if (null !== $reflection = $this->getThemeReflectionClass($classname)) { + /** @var class-string $class */ + $class = $reflection->getName(); + if (method_exists($class, 'getThemeMainClass')) { + return $class::getThemeMainClass(); + } + } + throw new RuntimeException('Theme class ' . $classname . ' does not exist.'); + } + + /** + * @param string $name + * + * @return string + */ + protected function validateName(string $name): string + { + if (1 !== preg_match('#^[A-Z][a-zA-Z]+$#', $name)) { + throw new LogicException('Theme name must only contain alphabetical characters and begin with uppercase letter.'); + } + + $name = trim(preg_replace('#(?:Theme)?(?:App)?$#', '', $name)); + if (!empty($name)) { + return $name; + } + throw new LogicException('Theme name is not valid.'); + } + + /** + * @return bool + */ + public function exists(): bool + { + if ($this->isProtected()) { + return true; + } + if ( + $this->filesystem->exists($this->getThemePath()) || + $this->filesystem->exists($this->projectDir . '/vendor/roadiz/' . $this->getThemeName()) + ) { + return true; + } + + return false; + } + + protected function getProtectedThemePath(): string + { + if ($this->filesystem->exists($this->projectDir . '/vendor/roadiz/' . $this->getThemeName())) { + return $this->projectDir . '/vendor/roadiz/' . $this->getThemeName(); + } elseif ($this->filesystem->exists($this->projectDir . '/themes/' . $this->getThemeName())) { + return $this->projectDir . '/themes/' . $this->getThemeName(); + } + throw new \InvalidArgumentException($this->getThemeName() . ' does not exist in project and vendor.'); + } + + /** + * Get real theme path from its name. + * + * Attention: theme could be located in vendor folder (/vendor/roadiz/roadiz) + * + * @return string Theme absolute path. + */ + public function getThemePath(): string + { + if (null === $this->themePath) { + if ($this->isProtected()) { + $this->themePath = $this->getProtectedThemePath(); + } elseif ($this->isValid()) { + $className = $this->getClassname(); + if (method_exists($className, 'getThemeFolder')) { + $this->themePath = $className::getThemeFolder(); + } + } else { + $this->themePath = $this->projectDir . '/themes/' . $this->getThemeName(); + } + } + return $this->themePath; + } + + /** + * @param class-string|null $className + * + * @return null|ReflectionClass + */ + public function getThemeReflectionClass(string $className = null): ?ReflectionClass + { + try { + if (null === $className) { + $className = $this->getClassname(); + } + $reflection = new ReflectionClass($className); + if ($reflection->isSubclassOf(AbstractController::class)) { + return $reflection; + } + } catch (ReflectionException $Exception) { + return null; + } + + return null; + } + + /** + * @return string + */ + protected function getThemeNameFromName(): string + { + if (in_array($this->name, self::$protectedThemeNames)) { + return $this->name; + } + + return $this->name . 'Theme'; + } + + /** + * @return string Theme name WITHOUT suffix + */ + public function getName(): string + { + return $this->name; + } + + /** + * @return string Theme name WITH suffix + */ + public function getThemeName(): string + { + return $this->themeName; + } + + /** + * @return class-string Theme class FQN + */ + public function getClassname(): string + { + if (null === $this->classname) { + $this->classname = $this->guessClassnameFromThemeName($this->getThemeName()); + } + return $this->classname; + } + + /** + * @return bool + */ + public function isValid(): bool + { + try { + $className = $this->getClassname(); + } catch (\InvalidArgumentException $exception) { + return false; + } + + try { + $reflection = new ReflectionClass($className); + if ($reflection->isSubclassOf(AbstractController::class)) { + return true; + } + } catch (ReflectionException $Exception) { + return false; + } + return false; + } +} diff --git a/lib/RoadizCompatBundle/src/Theme/ThemeResolverInterface.php b/lib/RoadizCompatBundle/src/Theme/ThemeResolverInterface.php new file mode 100644 index 00000000..58846a4b --- /dev/null +++ b/lib/RoadizCompatBundle/src/Theme/ThemeResolverInterface.php @@ -0,0 +1,51 @@ + symfony/framework-bundle ### +/.env.local +/.env.local.php +/.env.*.local +/config/secrets/prod/prod.decrypt.private.php +/public/bundles/ +/var/ +/vendor/ +###< symfony/framework-bundle ### + +###> symfony/phpunit-bridge ### +.phpunit.result.cache +/phpunit.xml +###< symfony/phpunit-bridge ### + +###> phpunit/phpunit ### +/phpunit.xml +.phpunit.result.cache +###< phpunit/phpunit ### +/lib/ +/.data/ + +###> squizlabs/php_codesniffer ### +/.phpcs-cache +/phpcs.xml +###< squizlabs/php_codesniffer ### +/report.txt +/composer.lock + +###> lexik/jwt-authentication-bundle ### +/config/jwt/*.pem +###< lexik/jwt-authentication-bundle ### +/symfony.lock + +# Created by https://www.toptal.com/developers/gitignore/api/symfony +# Edit at https://www.toptal.com/developers/gitignore?templates=symfony + +### Symfony ### +# Cache and logs (Symfony2) +/app/cache/* +/app/logs/* +!app/cache/.gitkeep +!app/logs/.gitkeep + +# Email spool folder +/app/spool/* + +# Cache, session files and logs (Symfony3) +/var/cache/* +/var/logs/* +/var/sessions/* +!var/cache/.gitkeep +!var/logs/.gitkeep +!var/sessions/.gitkeep + +# Logs (Symfony4) +/var/log/* +!var/log/.gitkeep + +# Parameters +/app/config/parameters.yml +/app/config/parameters.ini + +# Managed by Composer +/app/bootstrap.php.cache +/var/bootstrap.php.cache +/bin/* + +# Assets and user uploads +/web/bundles/ +/web/uploads/ + +# PHPUnit +/app/phpunit.xml + +# Build data +/build/ + +# Composer PHAR +/composer.phar + +# Backup entities generated with doctrine:generate:entities command +**/Entity/*~ + +# Embedded web-server pid file +/.web-server-pid + +### Symfony Patch ### +/web/css/ +/web/js/ + +# End of https://www.toptal.com/developers/gitignore/api/symfony diff --git a/lib/RoadizCoreBundle/.travis.yml b/lib/RoadizCoreBundle/.travis.yml new file mode 100644 index 00000000..316f0e9e --- /dev/null +++ b/lib/RoadizCoreBundle/.travis.yml @@ -0,0 +1,14 @@ +language: php +php: + - '8.0' + - '8.1' + - 'nightly' +jobs: + allow_failures: + - php: 'nightly' +install: + - composer install --dev --no-scripts --no-suggest + +script: + - vendor/bin/phpcs -p ./src + - vendor/bin/phpstan analyse -c phpstan.neon diff --git a/lib/RoadizCoreBundle/CHANGELOG.md b/lib/RoadizCoreBundle/CHANGELOG.md new file mode 100644 index 00000000..4c984e1c --- /dev/null +++ b/lib/RoadizCoreBundle/CHANGELOG.md @@ -0,0 +1,447 @@ +## 2.0.44 (2023-02-09) + + +### Bug Fixes + +* Add documentTranslation into document during persisting loop to allow fetching before flushing ([cc94cbe](https://github.com/roadiz/core-bundle/commit/cc94cbe0adf9d959cdcc42bcd216faf6bbfc66c9)) + +## 2.0.43 (2023-01-26) + + +### Bug Fixes + +* Enforce custom-form validation for API endpoints ([055201d](https://github.com/roadiz/core-bundle/commit/055201d8697c154f0f3bd631d78711020b9491bb)) +* Removed static return type for php74 compatibility ([ba0473a](https://github.com/roadiz/core-bundle/commit/ba0473a251e9b91c94044844891e06e8a6d0d3c9)) + +## 2.0.42 (2023-01-16) + +### Bug Fixes + +* Format CustomFormAttribute value to Date or DateTime string ([86df706](https://github.com/roadiz/core-bundle/commit/86df7063f429fd334022d93b6471fbfefdd1636c)) + +## 2.0.41 (2023-01-11) + +### Bug Fixes + +* Fixed recursive findEmailData to find "email" key in a complex form data ([44a9a89](https://github.com/roadiz/core-bundle/commit/44a9a899a31c5a727eb36d3940bc78ded89a4254)) + +## 2.0.40 (2022-11-22) + +### Features + +* Added tags and folders position field to serialization ([4a92a02](https://github.com/roadiz/core-bundle/commit/4a92a0209a92313482d57131a303e582a38fb716)) + +## 2.0.39 (2022-11-09) + +### Bug Fixes + +* Missing allowRequestSearching condition on EntityListManager ([6e0f606](https://github.com/roadiz/core-bundle/commit/6e0f606803ab36a79e8f9384bfc970bdd53a7c1b)) + +## 2.0.38 (2022-11-09) + +### Bug Fixes + +* Make EntityListManager request sorting and searching optional for security purposes ([5103cf6](https://github.com/roadiz/core-bundle/commit/5103cf6d5bbc1e64b7a7685339cdf04ac37ffd4f)) + +## 2.0.37 (2022-10-03) + +### Bug Fixes + +* **Normalization:** Set higher priority for `NodesSourcesPathNormalizer` ([05498d2](https://github.com/roadiz/core-bundle/commit/05498d24bada43db5f0eb3d512a19a0606cb5001)) + +## 2.0.36 (2022-09-30) + +### Features + +* **NodeType:** Automatically store JSON serialized node-type in project and update `./src/Resources/config.yml` file ([2e1dc8a](https://github.com/roadiz/core-bundle/commit/2e1dc8a99d502719aabc6d16f7667d7bc909425d)) +* **NodeType:** Remove JSON node-type field and update `./src/Resources/config.yml` after Node-type deletion ([32a325f](https://github.com/roadiz/core-bundle/commit/32a325f29ded18b0c0ea4a5ff0349743efb69974)) +* **Translation:** Let user choose source and destination translations ([8426fa0](https://github.com/roadiz/core-bundle/commit/8426fa0ab88d2061acc636c7c636a18403cc6b07)) + +## 2.0.35 (2022-09-28) + +### Features + +* Upgrade to `roadiz/documents` 2.0.6 minimum +* Dispatch async document messages only for DocumentFileUpdatedEvent, dispatch DocumentCreatedEvent on custom-form post ([e008da6](https://github.com/roadiz/core-bundle/commit/e008da6643b417a69e411d1d62891fdeefae49df)) + +## 2.0.34 (2022-09-21) + +### Bug Fixes + +* **Constraints:** Missing `CLASS_CONSTRAINT` for NodeTypeField class constraint ([08246c7](https://github.com/roadiz/core-bundle/commit/08246c7801ed0d7831ecace9ed49e00f2c7d82b2)) + +## 2.0.33 (2022-09-20) + +**Reverting**: https://github.com/api-platform/core/issues/4988 + +### Bug Fixes + +* **api-platform:** Revert to api-platform/core 2.6 ([1a23f28](https://github.com/roadiz/core-bundle/commit/1a23f282a123016260320358d6d93f86f80467d1)) + +## 2.0.32 (2022-09-20) + +### Features + +* **Solr:** Differentiate tags_txt for visible Folders/Tags and all_tags_txt for filtering Solr queries. ([9d0c7e5](https://github.com/roadiz/core-bundle/commit/9d0c7e59b0eeb6e60b921560028265e130e1e51d)) + +## 2.0.31 (2022-09-19) + +### Bug Fixes + +* **Translation:** NodesSourcesPathResolver must not use unavailable translations unless preview mode is active ([459580b](https://github.com/roadiz/core-bundle/commit/459580b7d1d2eb448473d30718901e3ad31a3a93)) + +## 2.0.30 (2022-09-19) + +Bug fixes due to *api-platform/core* upgrade to 2.7.0 + +### Bug Fixes + +* **api-platform:** Fixed ArchiveExtension `applyToCollection` method which was not restricted to archive operations ([6bfa530](https://github.com/roadiz/core-bundle/commit/6bfa530c1a2859f02f2b03593fe7f636be78ea3a)) +* **api-platform:** Increased `ArchiveExtension` service tag priority to be called before `PaginationExtension` ([3a7158a](https://github.com/roadiz/core-bundle/commit/3a7158ad6d47e052c008a93ae4a2e47f6246493e)) +* **api-platform:** Address nullable RequestStack in `AbstractFilter` constructor ([de9b901](https://github.com/roadiz/core-bundle/commit/de9b90161e75d9933c2edab34e817b1815d762fa)) +* **api-platform:** Fixed `AbstractFilter` deprecation using `AbstractContextAwareFilter` ([a85db5d](https://github.com/roadiz/core-bundle/commit/a85db5de112a12ab2b9211770d86dbf09f9ada70)) + +## 2.0.26 (2022-09-16) + +### Features + +* Migrated constraints from Symfony forms to global entity validation ([11741a3](https://github.com/roadiz/core-bundle/commit/11741a384fba8d630fc744034e506b1bf15c8d17)) + +## 2.0.25 (2022-09-15) + +### Features + +* Added Flex manifest and updated config files ([8ace107](https://github.com/roadiz/core-bundle/commit/8ace107e2a0448f13dec1af06f6c94ab6756706c)) +* Added PathResolverInterface::resolvePath `$allowNonReachableNodes` arg to restrict path resolution to reachable node-types ([d78754d](https://github.com/roadiz/core-bundle/commit/d78754d8708e4584e9f8dd26b2d8ec391c3e7afd)) +* Added `public` and `themes` dir in flex manifest ([305800d](https://github.com/roadiz/core-bundle/commit/305800dda9004505d622cc7413622c4a71cbf07b)) + +### Bug Fixes + +* Missing default configuration value for `healthCheckToken` ([28668c4](https://github.com/roadiz/core-bundle/commit/28668c43591d3b1ef7f9b3472f8f1be074c69543)) + +## 2.0.24 (2022-09-07) + +### Features + +* Added `DocumentVideoThumbnailMessageHandler` to wrap `ffmpeg` process and extract videos first frame as thumbnail ([4b7d096](https://github.com/roadiz/core-bundle/commit/4b7d0969a772717c077cf9b915388dbf98776254)) +* `ImageManager` is registered as a service to use app-wise configured driver ([cfa0b84](https://github.com/roadiz/core-bundle/commit/cfa0b845dda1fc9a101916e502ac201761797d68)) +* Moved all document processes from event-subscribers to async messenger, read AV media size and duration ([251b9b5](https://github.com/roadiz/core-bundle/commit/251b9b5dc514a4177765200822544ef1d5a06d68)) + +### Bug Fixes + +* Revert registering ImageManager as service since rezozero/intervention-request-bundle does it ([064c865](https://github.com/roadiz/core-bundle/commit/064c865678cd03d69985b6346436f834b56cd5d5)) + +## 2.0.23 (2022-09-06) + +### Bug Fixes + +* Force int progress start ([24247d2](https://github.com/roadiz/core-bundle/commit/24247d2bc99058f02a7e1b5f19ddc24ae55f7a07)) +* Upgraded rezozero Liform to handle properly FileType multiple option ([cd1b147](https://github.com/roadiz/core-bundle/commit/cd1b147b7308c3a94dee0f9a78840907001438e8)) + +## 2.0.22 (2022-09-06) + +### Bug Fixes + +* Folder names and Tags names must be quoted in Solr filter query parameters ([d68d9b5](https://github.com/roadiz/core-bundle/commit/d68d9b51f1e507c3c57ec8c09ca1ca3f5fdd4264)) + +## 2.0.21 (2022-09-06) + +### Bug Fixes + +* Always index all documents folder names to Solr, not only visible ones (i.e. to restrict documents search with an invisible folder) ([c76fffc](https://github.com/roadiz/core-bundle/commit/c76fffcaa61e5cc60d19b271c3d638aebb3c166f)) + +## 2.0.20 (2022-09-01) + +### Features + +* Added Folder `locked` and `color` fields, improved table indexes ([b8f344d](https://github.com/roadiz/core-bundle/commit/b8f344db0fcadbed3532127812467a5f295f061a)) +* Improved AbstractExplorerItem and AbstractExplorerItem ([66386d6](https://github.com/roadiz/core-bundle/commit/66386d6c5a63828577f2ddf24ad58296b8b379de)) + +## 2.0.19 (2022-08-29) + +### Bug Fixes + +* Updated *rezozero/tree-walker* in order to extend `AbstractCycleAwareWalker` and prevent cyclic children collection ([eb80381](https://github.com/roadiz/core-bundle/commit/eb80381738f7fa90cf1aa466827522982d4a2036)) + +## 2.0.18 (2022-08-05) + +### Bug Fixes + +* Missing validator translation message ([33648a3](https://github.com/roadiz/core-bundle/commit/33648a3b1b36010459d39dba73929a018700dece)) +* **Security:** Use QueryItemExtension and QueryCollectionExtension to filter out non-published nodes-sources and raw documents ([f7c4688](https://github.com/roadiz/core-bundle/commit/f7c4688eee09034c7317de7c3fd01be7845e4f1d)) + +## 2.0.17 (2022-08-02) + +### Bug Fixes + +* **SearchEngine:** Use `Solarium\Core\Client\Client` instead of `Solarium\Client` because it's not compatible with Preload (defined constant at runtime) ([320df16](https://github.com/roadiz/core-bundle/commit/320df160182464f2aa35a82813f1676ce428d59c)) + +## 2.0.16 (2022-08-01) + +### Bug Fixes + +* **Document:** Fixed context groups undefined key ([8bbdc31](https://github.com/roadiz/core-bundle/commit/8bbdc313b29ecebf2ef594aec03cb30d7b487ea9)) + +## 2.0.15 (2022-08-01) + +### Bug Fixes + +* **Document:** Fixed document DTO thumbnail when document is Embed (it's an image too because an image has been downloaded from platform) ([0d7fef4](https://github.com/roadiz/core-bundle/commit/0d7fef4ed44fc2f2867eaf5ea54efb189bda404a)) + +## 2.0.14 (2022-08-01) + +### Bug Fixes + +* **ArchiveFilter:** Prevent normalizing not-string values ([b1fe49e](https://github.com/roadiz/core-bundle/commit/b1fe49ea909ec59170c5c5cf13a03465ceab901a)) + +## 2.0.13 (2022-07-29) + +### Bug Fixes + +* Remove useless eager join on document downscaledDocuments on DocumentRepository ([d821586](https://github.com/roadiz/core-bundle/commit/d82158616c6e8259c9264715e458bf1e2f0ccdb7)) + +## 2.0.12 (2022-07-29) + +### Bug Fixes + +* **Serializer:** Ignore can only be added on methods beginning with "get", "is", "has" or "set" ([78b52aa](https://github.com/roadiz/core-bundle/commit/78b52aa794413b73f67b08efad787300f6ebf07a)) + +## 2.0.11 (2022-07-29) + +### Features + +* Added `altSources` to Document DTO and optimize document downscaled relationship ([82a5fd6](https://github.com/roadiz/core-bundle/commit/82a5fd6cd0e37f15bff81655d34f63f9b2897fb3)) + +## 2.0.10 (2022-07-29) + +### Bug Fixes + +* DocumentFinder now extends AbstractDocumentFinder ([670516a](https://github.com/roadiz/core-bundle/commit/670516a9fbbdb7d312c356acc7f5626059f2150d)) + +## 2.0.9 (2022-07-25) + +### Bug Fixes + +* **SearchEngine:** Do no trigger error on Solr messages if Solr is not available ([785c559](https://github.com/roadiz/core-bundle/commit/785c5593db7a0fa4a3b11e3d277a035ff63d2361)) + +## 2.0.8 (2022-07-21) + +### Bug Fixes + +* Do not index empty arrays since [solariumphp/solarium 6.2.5](https://github.com/solariumphp/solarium/issues/1023) breaks empty array indexing ([c9da177](https://github.com/roadiz/core-bundle/commit/c9da177fd9af28e273048373f45c846ec8ca75d7)) + +## 2.0.7 (2022-07-20) + +### Features + +* Added new `NodeTranslator` service and remove dead code on User entity ([7f211c5](https://github.com/roadiz/core-bundle/commit/7f211c5354dac0ec953138a514e5d4e82f06e41f)) + +## 2.0.6 (2022-07-20) + +### Bug Fixes + +* Attach documents to custom-form notification emails ([c213e87](https://github.com/roadiz/core-bundle/commit/c213e87f9095ac1e21bda17c08cf7d5f389dff7b)) + +## 2.0.5 (2022-07-13) + +### Features + +* Added `NotFilter` ([29a608d](https://github.com/roadiz/core-bundle/commit/29a608d76782a68ddfa2e25b7e4ab5e8081cd3e2)) +* Purge custom-form answers **documents** as well when retention time is over. ([a00a619](https://github.com/roadiz/core-bundle/commit/a00a619b5458c443f5099e59dfa964518d49e88d)) + +## 2.0.4 (2022-07-11) + +### ⚠ BREAKING CHANGES + +* WebResponseInterface now requires `getItem(): ?PersistableInterface` method to be implemented. + +### Bug Fixes + +* Set context translation from a WebResponseInterface object ([fbde288](https://github.com/roadiz/core-bundle/commit/fbde288f157f6c2bd84aadb786a8f23ed73300c2)) + +## 2.0.3 (2022-07-06) + +### Bug Fixes + +* Mailer test command sender and origin emails ([ae26d01](https://github.com/roadiz/core-bundle/commit/ae26d014fcf62c878f3c9e08c260313b9d855752)) + +## 2.0.2 (2022-07-05) + +### Features + +Added true filtrable archives endpoint extension for any Doctrine entities ([597803d](https://github.com/roadiz/core-bundle/commit/597803d37cb324c3d7076f323a5821d497e9fbf5)). +You need to add a custom collection operation for each Entity you want to enable archives for: + +```yaml +# config/api_resources/nodes_sources.yml +RZ\Roadiz\CoreBundle\Entity\NodesSources: + iri: NodesSources + shortName: NodesSources + collectionOperations: + # ... + archives: + method: 'GET' + path: '/nodes_sources/archives' + pagination_enabled: false + pagination_client_enabled: false + archive_enabled: true + archive_publication_field_name: publishedAt + normalization_context: + groups: + - get + - archives + openapi_context: + summary: Get available NodesSources archives + parameters: ~ + description: | + Get available NodesSources archives (years and months) based on their `publishedAt` field +``` + + +## 2.0.1 (2022-07-05) + +### Features + +* Added `IntersectionFilter` to create intersection with tags and folders aware entities. ([25c1dc5](https://github.com/roadiz/core-bundle/commit/25c1dc54b46dfadc02ed17b8e9de892eed784d73)) + +## 2.0.0 (2022-07-01) + +### ⚠ BREAKING CHANGES + +* `LoginRequestTrait` using Controller must implement getUserViewer() method. +* You must now define getByPath itemOperation for each routable API resource. +* Solr handler must be used with SolrSearchResults (for results and count) +* Rename @Rozier to @RoadizRoadiz + +### Features + +* Added Realm, RealmNode types, events and async messenging logic to propagate realms relationships in node-tree. ([c53cbec](https://github.com/roadiz/core-bundle/commit/c53cbec87f03178ed7e9f9ea8969426ab332ed33)) +* Accept Address object as receiver ([4f5f925](https://github.com/roadiz/core-bundle/commit/4f5f925cf50a9e66f3be4db0d0e3f605465143c6)) +* Add node' position to its DTO ([6dae0d6](https://github.com/roadiz/core-bundle/commit/6dae0d6f53c2bc431315bac294cef5ac1970193d)) +* Added `--dry-run` option for documents:files:prune command ([8b61694](https://github.com/roadiz/core-bundle/commit/8b616942dd9967aa26a2e1844fc544edbfd09fcf)) +* Added CircularReferenceHandler to expose only object ID ([8c9ddbd](https://github.com/roadiz/core-bundle/commit/8c9ddbd89210b9c88a1d9c7af3ff03d5fd8706d8)) +* Added custom-form retention time ([22383e9](https://github.com/roadiz/core-bundle/commit/22383e91eb140c61dc019447536f2be2e90a0488)) +* Added default NodesSources search and archives controller ([b8ff98b](https://github.com/roadiz/core-bundle/commit/b8ff98b4e0048bfec2ec178a0c6d7660ff5c6ccf)) +* Added document CLI command to hash files and find duplicates ([d138a2a](https://github.com/roadiz/core-bundle/commit/d138a2ab805494de3e74a8977499603180c636d8)) +* Added document Dto width, height and mimeType ([62958a3](https://github.com/roadiz/core-bundle/commit/62958a3091c9c90e15b5353088cbbdb8fa2ff229)) +* Added DocumentRepository alterQueryBuilderWithCopyrightLimitations method ([637a0b7](https://github.com/roadiz/core-bundle/commit/637a0b7bbf37a0501dcb81649eee1c9943e89459)) +* Added documents file_hash and file_hash_algorithm for duplicate detection. ([4549ada](https://github.com/roadiz/core-bundle/commit/4549ada40dba6d762bf85bf62cf401e265c2d176)) +* Added generate:api-resources command ([25fd64c](https://github.com/roadiz/core-bundle/commit/25fd64c322c932b49c9ab1c5575993e338806760)) +* Added HealthCheckController and appVersion config ([54bf276](https://github.com/roadiz/core-bundle/commit/54bf276bf3d710e4e3226744b20ce387958227f8)) +* Added lexik_jwt_authentication ([bd5826d](https://github.com/roadiz/core-bundle/commit/bd5826d168b7373feb4eebb769fbf0b53d8a5575)) +* Added missing Document DTO externalUrl ([cbce6f1](https://github.com/roadiz/core-bundle/commit/cbce6f19e35363438b57d8b67264d3cac5981512)) +* Added new Archive filter on datetime fields ([0bae8d3](https://github.com/roadiz/core-bundle/commit/0bae8d3efe562fb81b6da051c11842aeb0c09165)) +* Added new Document copyrightValidSince and Until fields to restrict document display. ([40a31c2](https://github.com/roadiz/core-bundle/commit/40a31c2e4ebc8c313ee6433c12c66403a436728e)) +* Added new role ROLE_ACCESS_DOCUMENTS_LIMITATIONS ([bc564fd](https://github.com/roadiz/core-bundle/commit/bc564fd8d3e5c8ed853c76354a89ed44f359fdca)) +* Added new role: ROLE_ACCESS_CUSTOMFORMS_RETENTION ([b3586c4](https://github.com/roadiz/core-bundle/commit/b3586c4c57fc5869dac226b9fb81e1e0b2cd24fb)) +* Added new UserJoinedGroupEvent and UserLeavedGroupEvent events ([e12d6e4](https://github.com/roadiz/core-bundle/commit/e12d6e4be12e89fdab1bf31e64c77c7329d2a2bb)) +* Added node-source archive operation logic (without filters) ([994d9bc](https://github.com/roadiz/core-bundle/commit/994d9bc14fe334168f5868dab2ba7d2ecf203bdd)) +* Added OpenId authenticator ([5cf4383](https://github.com/roadiz/core-bundle/commit/5cf43836f9a8a95ed97aacf0f3a412169b34f52d)) +* Added preview user provider and JwtExtensiont to generate secure jwt for frontend previewing ([76d81c0](https://github.com/roadiz/core-bundle/commit/76d81c0799a3df0c93c45556cc56adedae9bd1d7)) +* Added Realm and RealmNode entities for defining security policies inside node tree ([99ad2a5](https://github.com/roadiz/core-bundle/commit/99ad2a53051ca9c96dab9d3e908b5c1ebf0491c8)) +* Added Realm Security Voter, Normalizer and WebResponse logic ([f35083e](https://github.com/roadiz/core-bundle/commit/f35083ed2269718be149ec477d0a3178b2ae8a13)) +* Added RealmResolverInterface to get Nodes realms and check security ([6fe7a00](https://github.com/roadiz/core-bundle/commit/6fe7a00d21991d70fdbdcc5934c8662cfafed181)) +* Added RememberMeBadge ([1cf563c](https://github.com/roadiz/core-bundle/commit/1cf563c22d95f484e7721880141043907c5f5894)) +* Added Solr document search `copyrightValid` criteria shortcut ([66f4215](https://github.com/roadiz/core-bundle/commit/66f4215497c45ad319f8bcbbfdd1ccd74ec7c560)) +* Added translation in serializer context from _locale ([db0b45b](https://github.com/roadiz/core-bundle/commit/db0b45bfc611947b92d650dc34e8be72fad23ba1)) +* Added Translation name to Link header to build locale switch menus ([9785438](https://github.com/roadiz/core-bundle/commit/978543882cb96b3e89063703485d0ff913e9cfa2)) +* Added validation constraints and groups directly on User entity ([82e01f3](https://github.com/roadiz/core-bundle/commit/82e01f3e8fca7e3078afbe7a44f4d940b0a8079e)) +* Added validators translation messages ([a61b9d8](https://github.com/roadiz/core-bundle/commit/a61b9d80c149540f6567f23bd1471a361eff514c)) +* Configured `rezozero/crypto` to use encoded settings ([32f59d6](https://github.com/roadiz/core-bundle/commit/32f59d65064785560d4d9c01857c8e3d9285b3b8)) +* ContactFormManager flatten form data with real form labels and prevent g-recaptcha-response to be sent ([53e7c9d](https://github.com/roadiz/core-bundle/commit/53e7c9df23741c0eab71f66811159801149dad65)) +* Deprecated LoginAttemptManager in favor of built-in Symfony login throttling ([2d4a10e](https://github.com/roadiz/core-bundle/commit/2d4a10ec97969364ba5e829c650e3321a59d3607)) +* Do not index not visible tags and folder into Solr ([d8fc516](https://github.com/roadiz/core-bundle/commit/d8fc5167371941ed44530186abcd49a904d773e1)) +* Do not search for a locale if first token exceed 11 chars ([9f614d8](https://github.com/roadiz/core-bundle/commit/9f614d8b1c1f39a78d9dc6095eb4284161f7c979)) +* **Document:** Added document-translation external URL and missing DB indexes ([c346f7b](https://github.com/roadiz/core-bundle/commit/c346f7bdddeb670b30997d3884ef7ec1ff987efb)) +* **documents:** Added mediaDuration to Document DTO ([41673d6](https://github.com/roadiz/core-bundle/commit/41673d63ecc50dcf5ac76e6d823a8d224421326e)) +* Filter and Sort Translations by availability, default and locale ([47605f8](https://github.com/roadiz/core-bundle/commit/47605f8cd4900cc4d50de2ccc6294abb2899510b)) +* find email from any contact form compound ([3d55930](https://github.com/roadiz/core-bundle/commit/3d559300dc57acf6b20244f947b4c12fa1d386d3)) +* Force API Platform to look for real resource configuration and serialization context ([9ee9f42](https://github.com/roadiz/core-bundle/commit/9ee9f42a098340344a73746585887a011a0b561a)) +* FormErrorSerializerInterface and RateLimiters ([31c6ca8](https://github.com/roadiz/core-bundle/commit/31c6ca84c6576abb5240d7db8170cd7d53b2c869)) +* Index documents copyright limitations dates ([f6bbf0b](https://github.com/roadiz/core-bundle/commit/f6bbf0b50181bab746d6b80dad9c33d4ea6bfebc)) +* Jwt authentication supports LoginAttemptManager ([eee4c08](https://github.com/roadiz/core-bundle/commit/eee4c083d0c0223e02bfbc2e4f4ff4a0c6f0fdc8)) +* LoginRequestTrait requires UserViewer ([544cdcc](https://github.com/roadiz/core-bundle/commit/544cdcc5c26a364a5b21d2ef99492a6d9feb0691)) +* Made alterQueryBuilderWithAuthorizationChecker method public to use it along with Repository ([c567cc5](https://github.com/roadiz/core-bundle/commit/c567cc5cbf96bc86fc6a724e833fc24edff434c2)) +* Made AutoChildrenNodeSourceWalker overridable and cacheable ([226ae1a](https://github.com/roadiz/core-bundle/commit/226ae1a10ac30474e24d8e9bef7f082703b016f3)) +* Made WebResponse as an API Resource ([b778647](https://github.com/roadiz/core-bundle/commit/b778647dd6561d74e730746bf478650178936673)) +* migrate custom-forms preview to Recaptcha v3 script ([1b1bfb1](https://github.com/roadiz/core-bundle/commit/1b1bfb15198f8ceea6954e2e99b5344417bd6d94)) +* Moved all OpenID logic to RoadizRozierBundle as it only supports authentication to backoffice. ([0171cbb](https://github.com/roadiz/core-bundle/commit/0171cbb5492ea52c9292c41d92a5895b071db95c)) +* Moved Security/Authentication/OpenIdAuthenticator to roadiz/openid package ([4f4e391](https://github.com/roadiz/core-bundle/commit/4f4e3919cc549fb824036010ec1414a865c0488d)) +* New DocumentArchiver util to create ZIP archive from documents ([be8a35f](https://github.com/roadiz/core-bundle/commit/be8a35f86f264932ee32964036a81cd56815ab8c)) +* NodesSourcesPathResolver can resolve home faster, and resolve default translation based on Accept-Language HTTP header ([00db41a](https://github.com/roadiz/core-bundle/commit/00db41a3e252f1464dbd99cf417da8628273e31b)) +* Nullable discovery openId service ([ff555c3](https://github.com/roadiz/core-bundle/commit/ff555c33734d5b564dc248c105cb277dbde2dfbe)) +* Only requires symfony/security-core ([f094ca2](https://github.com/roadiz/core-bundle/commit/f094ca260b0ccf6dc0c17920a697aa09131f04a3)) +* Optimize NodesSourcesLinkHeaderEventSubscriber with repository method ([a4e5e37](https://github.com/roadiz/core-bundle/commit/a4e5e37fe389d55a6c695f9d880d85d181452c3c)) +* postUrl for custom-form dto transformer ([7b3f00f](https://github.com/roadiz/core-bundle/commit/7b3f00fb7ad2d26f4091c87619cf5d0120d04ad2)) +* **redirections:** Use recursive path normalization to normalize redirection to redirection ([4b4b03f](https://github.com/roadiz/core-bundle/commit/4b4b03fd7cec11b3462a63ca026d1710af635bd1)) +* Refactored document search handler and removed deprecated ([4cfb5df](https://github.com/roadiz/core-bundle/commit/4cfb5dfe58daf0cdcec08629d532fba844ea93c1)) +* refactored Document translation indexing using DocumentTranslationIndexingEvent, and make it document indexing overridable ([2f4126c](https://github.com/roadiz/core-bundle/commit/2f4126c02420426d0d2ebe49292c3f9c9d0214b0)) +* Removed hide_roadiz_version parameter from DB to remove useless DB query on assets requests ([b7ad3a7](https://github.com/roadiz/core-bundle/commit/b7ad3a71190abc85ea24498fb92e9a5f69ffd707)) +* Rename @Rozier to @RoadizRoadiz ([a5ebc4a](https://github.com/roadiz/core-bundle/commit/a5ebc4a7d1fb532165edca1dd36dbd65461c59da)) +* Search existing realm-node root or inheritance when editing a node (moving) ([c718059](https://github.com/roadiz/core-bundle/commit/c71805978c1406ff3fa55e4fc2b22f49b80fa1bd)) +* Serialize tag parent in DTO ([c48fb11](https://github.com/roadiz/core-bundle/commit/c48fb11681c44b8cc0a7126c58ce1a065b30a728)) +* set real _api_resource_class for GetWebResponseByPathController ([f9c0804](https://github.com/roadiz/core-bundle/commit/f9c080447501baf988f027aa719abd77c4676724)) +* Simplify UserLocaleSubscriber ([c79bf80](https://github.com/roadiz/core-bundle/commit/c79bf80d5537a1d10d2cd8e25d9616bc67eb25ee)) +* Support exception in Log messages context ([3159a6c](https://github.com/roadiz/core-bundle/commit/3159a6c40aca76ccbb86bacf599157c961958559)) +* Support non-available locales if previewing ([72cdf19](https://github.com/roadiz/core-bundle/commit/72cdf19563741fa5ed97f44f2d41a9ade9737f4b)) +* Support non-available locales if previewing ([ca1ac63](https://github.com/roadiz/core-bundle/commit/ca1ac631206dcb38cc7ee32f066a45c553c97839)) +* UniqueNodeGenerator: If parent has only one translation, use parent translation instead of default one for creating children ([d463d02](https://github.com/roadiz/core-bundle/commit/d463d024a171e245ed06f7b1d2b201dae1bf623e)) +* Use a factory to create NodeNamePolicyInterface with settings; ([a5a9b9d](https://github.com/roadiz/core-bundle/commit/a5a9b9d90a49935f2a2f5647c9d75ac5f261707e)) +* UserProvider support searching user by username or email ([4e6ad3c](https://github.com/roadiz/core-bundle/commit/4e6ad3c74fa56c4a823fc9c01f222d42bf8ee4dd)) + + +### Bug Fixes + +* Accept nullable DocumentOutput relativePath and mimeType ([910bc8f](https://github.com/roadiz/core-bundle/commit/910bc8fb10861eb38eadc3488349894efd56dc05)) +* Added Assert annotations on User entity for API platform validation ([7685f8e](https://github.com/roadiz/core-bundle/commit/7685f8e0496a16dc0bd989285afb7b2c2a4b110c)) +* Added email default sender name with Site name ([d9842ac](https://github.com/roadiz/core-bundle/commit/d9842ac654fc601690c898435822709a1a258414)) +* Added getByPath itemOperation into generate command ([220c291](https://github.com/roadiz/core-bundle/commit/220c291de268902071e82e5a8b26c07f5cc75c1e)) +* allow null string in AbstractSolarium::cleanTextContent ([aa418fc](https://github.com/roadiz/core-bundle/commit/aa418fccacc4cb7995badd070a6efb176428210d)) +* Cache pools to clear ([0cd36c0](https://github.com/roadiz/core-bundle/commit/0cd36c073f8ef35f89da887c1250db6441464344)) +* Casting attribute value to string when number input ([5263140](https://github.com/roadiz/core-bundle/commit/5263140cf9c08f79af32ae6e34f47adc84084df9)) +* Change Discovery definition argument ([bed0e1b](https://github.com/roadiz/core-bundle/commit/bed0e1b3db9471203097eb79abf1243649c44692)) +* Changed LocaleSubscriber priority ([de920ed](https://github.com/roadiz/core-bundle/commit/de920ed3c7aa6b93b2c931885152171b2ce17f52)) +* clear existing cache pool in SchemaUpdater ([62a01af](https://github.com/roadiz/core-bundle/commit/62a01af1162ec4a91b9822d323172a1da44ad528)) +* Configuration tree type ([de47a3c](https://github.com/roadiz/core-bundle/commit/de47a3cf4a5b8e698ae24a4883cc33e46feebc39)) +* Context getAttribute comparison ([016df90](https://github.com/roadiz/core-bundle/commit/016df902f7710db0f04115639be744a415112298)) +* do not set api resource GetWebResponseByPathController, it breaks serialization context ([49ccf2f](https://github.com/roadiz/core-bundle/commit/49ccf2f9f3720360b7e21955cd799b27a59afc71)) +* Doctrine batch iterating for Solr indexing ([255699a](https://github.com/roadiz/core-bundle/commit/255699ab0dfb6068d39b58fc178d7b08c7eb28b8)) +* Fix emptySolr query syntax ([717458f](https://github.com/roadiz/core-bundle/commit/717458f0538752736cf7fa4aa05fa80a0055e0a1)) +* Fix ExplorerItemProviderType using asMultiple instead of special multiple option. ([8fdc3da](https://github.com/roadiz/core-bundle/commit/8fdc3da5019d0cb0cb6b81ee04d4e7f91ec8e513)) +* Ignore getDefaultSeo ([e2f1a57](https://github.com/roadiz/core-bundle/commit/e2f1a578d046fa5afe3f81fafe8d46f62c3dbce8)) +* Improved Recaptcha fields Contraint and naming ([4121345](https://github.com/roadiz/core-bundle/commit/4121345ca37626b5f091d2894068b4fb4e913d63)) +* InversedBy relation, shorter log cleanup duration ([67c3ef0](https://github.com/roadiz/core-bundle/commit/67c3ef024e2305b51c15c63adad75a10bd8f06ee)) +* Missing Liform transformers ([0e8cb2b](https://github.com/roadiz/core-bundle/commit/0e8cb2b33bba4d7799cc1bb8b728c4880ec50e6e)) +* Missing RedirectionPathResolver to resolve redirections ([eaa99c7](https://github.com/roadiz/core-bundle/commit/eaa99c72a7838a4c59034b82c6be7d13f631fd60)) +* missing trans on form error message ([68e38a6](https://github.com/roadiz/core-bundle/commit/68e38a6583c87774538e06dc88492188ea76e773)) +* New SolariumDocumentTranslation constructor signature ([7f1f8b5](https://github.com/roadiz/core-bundle/commit/7f1f8b591a9bed2ccce55c5f6159392daab30a7d)) +* NodesSourcesDto title must accept nullable string ([bf3dec2](https://github.com/roadiz/core-bundle/commit/bf3dec2487ef7f9e70eba445a85d123a0df38d85)) +* non-existent cache.doctrine.orm.default.result cache pool ([e5bd921](https://github.com/roadiz/core-bundle/commit/e5bd9217f92dff7473552fbbd95146e16d0535a8)) +* Nullable and strict typing AttributeValueTranslation ([4e4bb0a](https://github.com/roadiz/core-bundle/commit/4e4bb0a29b7c8146cc821698515d57b344829bd5)) +* nullable custom-form email ([4fbeb58](https://github.com/roadiz/core-bundle/commit/4fbeb5844776e05d133dcd1bf0680401fee73056)) +* Nullable roadiz_core.solr.client service reference ([2592084](https://github.com/roadiz/core-bundle/commit/2592084fc4cf79c8ff9942ceea55face1d8999ed)) +* Only provide Link header for available translations or previewing user ([3255f58](https://github.com/roadiz/core-bundle/commit/3255f5896f582dd0a496de579fead2522c6e6633)) +* OpenIdJwtConfigurationFactory configuration ([800b97b](https://github.com/roadiz/core-bundle/commit/800b97b291b22ff8fcc8763a709131420f451626)) +* Prevent hashing non-existing document file ([5e92858](https://github.com/roadiz/core-bundle/commit/5e92858bb1fcdec60cd2ffeb0b1e18f2eaa97185)) +* Remove NodeType repository class as well during deletion ([9c4c49e](https://github.com/roadiz/core-bundle/commit/9c4c49e1c5da2a056a9b7ac42819c94f2db3758a)) +* Removed method dependency on UrlGeneratorInterface ([cbe9675](https://github.com/roadiz/core-bundle/commit/cbe9675ac20b59df2e0fd2595064a542e45468ef)) +* removed optional cache-pools from SchemaUpdater ([ff2de22](https://github.com/roadiz/core-bundle/commit/ff2de227976e81f7abb5453f9e7ab8a607804303)) +* Removed request option from Recaptcha constraint and using Form classes ([60a04d4](https://github.com/roadiz/core-bundle/commit/60a04d4e6582233cf08fac0dfd5f4c52b5f4c8eb)) +* Rewritten node transtyper ([c1309dd](https://github.com/roadiz/core-bundle/commit/c1309ddbbdffbbf7b4613e33f41d5db1a35b1435)) +* Roadiz LocaleSubscriber must be aware of _locale in query parameters too. ([65fe081](https://github.com/roadiz/core-bundle/commit/65fe0811cbefb2e0730ba6a4f7541c53ddd0b4bd)) +* SolrDeleteMessage handler ([fefe729](https://github.com/roadiz/core-bundle/commit/fefe729a73684a816d9feffc0eac79c26c52af98)) +* support ld+json exception ([fdce0d1](https://github.com/roadiz/core-bundle/commit/fdce0d102b82026ec9051723ea2168f720613cac)) +* transactional email style for button ([c588994](https://github.com/roadiz/core-bundle/commit/c5889946d20b1fd583c7f7543fc65c8237f1429d)) +* TranslationSubscriber dispatch CachePurgeRequestEvent ([4099304](https://github.com/roadiz/core-bundle/commit/409930477f91798b787d167ac0fd9b610d1373be)) +* Uniformize custom-form error JSON response with other form-error responses ([a817fa4](https://github.com/roadiz/core-bundle/commit/a817fa4eeaf1eb7899fe27ad25e17566621bf508)) +* unnecessary nullable ObjectManager ([218bbd1](https://github.com/roadiz/core-bundle/commit/218bbd15a4e236c20141eabb776aa84028e2d6b2)) +* Update tag and document timestamps when translations were edited ([49774d8](https://github.com/roadiz/core-bundle/commit/49774d8deaa85d46103f8cfccc1f6fcb66a28c23)) +* Use empty form name for custom-form ([202ea2a](https://github.com/roadiz/core-bundle/commit/202ea2a63c695ebc7a5d9a0432dc57852af09ce2)) +* Use JSON status prop for status code, not message ([2a4f498](https://github.com/roadiz/core-bundle/commit/2a4f498aab74313b07f53ffbdc4ecb0db2adcd4b)) +* Use Paginator to avoid pagination issues during Solr node indexing ([f43cae9](https://github.com/roadiz/core-bundle/commit/f43cae9d3ffab43a5a3fa70d1f3bdc29f8b18315)) +* Use PriorityTaggedServiceTrait trait to respect tagged service priority ([4e31229](https://github.com/roadiz/core-bundle/commit/4e31229b8c84285fdc445050aafd2e7c7d6306c6)) +* Use `rezozero/liform` fork ([863893c](https://github.com/roadiz/core-bundle/commit/863893c8c816289fcee7547d82f35eff8cd4fbeb)) +* Use Security to create Preview user when isGranted ([133c6f8](https://github.com/roadiz/core-bundle/commit/133c6f889edb0f8d1ad9216bb54bfb6cb7560512)) +* Use single_text widget for Date and DateTime customForm fields ([5bfa1d3](https://github.com/roadiz/core-bundle/commit/5bfa1d3bbc7a04e629fc2c63252f11a68d313dcc)) +* User implements getUserIdentifier ([0355b1c](https://github.com/roadiz/core-bundle/commit/0355b1cf44911408d2467582dc64552d2d6ac3b4)) + diff --git a/lib/RoadizCoreBundle/LICENSE.md b/lib/RoadizCoreBundle/LICENSE.md new file mode 100644 index 00000000..d4d8a009 --- /dev/null +++ b/lib/RoadizCoreBundle/LICENSE.md @@ -0,0 +1,9 @@ +The MIT License (MIT) + +Copyright © 2023 Ambroise Maupate + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/lib/RoadizCoreBundle/Makefile b/lib/RoadizCoreBundle/Makefile new file mode 100644 index 00000000..16ef8148 --- /dev/null +++ b/lib/RoadizCoreBundle/Makefile @@ -0,0 +1,3 @@ +test: + php -d "memory_limit=-1" vendor/bin/phpcbf --report=full --report-file=./report.txt -p ./src + php -d "memory_limit=-1" vendor/bin/phpstan analyse -c phpstan.neon diff --git a/lib/RoadizCoreBundle/README.md b/lib/RoadizCoreBundle/README.md new file mode 100644 index 00000000..458fd9b8 --- /dev/null +++ b/lib/RoadizCoreBundle/README.md @@ -0,0 +1,61 @@ +# Roadiz Core bundle + +![Run test status](https://github.com/roadiz/core-bundle/actions/workflows/run-test.yml/badge.svg?branch=develop) + +Installation +============ + +Make sure Composer is installed globally, as explained in the +[installation chapter](https://getcomposer.org/doc/00-intro.md) +of the Composer documentation. + +Applications that use Symfony Flex +---------------------------------- + +Open a command console, enter your project directory and execute: + +```console +$ composer require roadiz/core-bundle +``` + +Applications that don't use Symfony Flex +---------------------------------------- + +### Step 1: Download the Bundle + +Open a command console, enter your project directory and execute the +following command to download the latest stable version of this bundle: + +```console +$ composer require roadiz/core-bundle +``` + +### Step 2: Enable the Bundle + +Then, enable the bundle by adding it to the list of registered bundles +in the `config/bundles.php` file of your project: + +```php +// config/bundles.php + +return [ + // ... + \RZ\Roadiz\CoreBundle\RoadizCoreBundle::class => ['all' => true], +]; +``` + +## Configuration + +- Create folders: `public/assets`, `public/themes`, `public/files`, `themes/`, `var/files` for app documents and runtime classes +- Copy and merge `@RoadizCoreBundle/config/packages/*` files into your project `config/packages` folder +- Make to change your `framework.session.name` if you have multiple website running on the same localhost +- Add current bundle and `rezozero/intervention-request-bundle` routes to your project: +```yaml +# config/routes.yaml +roadiz_core: + resource: "@RoadizCoreBundle/config/routing.yaml" + +rz_intervention_request: + resource: "@RZInterventionRequestBundle/Resources/config/routing.yml" + prefix: / +``` diff --git a/lib/RoadizCoreBundle/composer.json b/lib/RoadizCoreBundle/composer.json new file mode 100644 index 00000000..71890036 --- /dev/null +++ b/lib/RoadizCoreBundle/composer.json @@ -0,0 +1,138 @@ +{ + "name": "roadiz/core-bundle", + "license": "MIT", + "keywords": [ + "cms", + "backoffice", + "rezo zero" + ], + "authors": [ + { + "name": "Ambroise Maupate", + "email": "ambroise@roadiz.io", + "homepage": "https://www.roadiz.io", + "role": "Lead developer" + } + ], + "type": "symfony-bundle", + "minimum-stability": "dev", + "prefer-stable": true, + "require": { + "php": ">=8.0", + "ext-ctype": "*", + "ext-iconv": "*", + "ext-zip": "*", + "ext-json": "*", + "api-platform/core": "~2.7.0", + "doctrine/annotations": "^1.0", + "doctrine/doctrine-bundle": "^2.3", + "doctrine/doctrine-migrations-bundle": "^3.1", + "doctrine/orm": "^2.14.1", + "league/flysystem": "^3.0", + "league/flysystem-bundle": "^3.0", + "gedmo/doctrine-extensions": "~3.10.0", + "inlinestyle/inlinestyle": "~1.2.7", + "james-heinrich/getid3": "^1.9", + "jms/serializer": "^3.9.0", + "jms/serializer-bundle": "^3.10.0", + "lexik/jwt-authentication-bundle": "^2.13", + "phpdocumentor/reflection-docblock": "^5.2", + "phpoffice/phpspreadsheet": "^1.15", + "rezozero/crypto": "^1.0.0", + "rezozero/intervention-request-bundle": "~3.0.0", + "rezozero/liform-bundle": "^0.18.1", + "rezozero/tree-walker": "^1.3.0", + "roadiz/doc-generator": "2.1.x-dev", + "roadiz/documents": "2.1.x-dev", + "roadiz/dts-generator": "2.1.x-dev", + "roadiz/entity-generator": "2.1.x-dev", + "roadiz/jwt": "2.1.x-dev", + "roadiz/markdown": "2.1.x-dev", + "roadiz/models": "2.1.x-dev", + "roadiz/nodetype-contracts": "~1.1.2", + "roadiz/random": "2.1.x-dev", + "rollerworks/password-common-list": "^0.2.0", + "rollerworks/password-strength-bundle": "^2.2", + "scienta/doctrine-json-functions": "^4.2", + "sensio/framework-extra-bundle": "^6.1", + "solarium/solarium": "^6.0.4", + "symfony-cmf/routing": "^2.3.3", + "symfony-cmf/routing-bundle": "^2.5", + "symfony/asset": "5.4.*", + "symfony/cache": "5.4.*", + "symfony/console": "5.4.*", + "symfony/dotenv": "5.4.*", + "symfony/expression-language": "5.4.*", + "symfony/flex": "^v1.19.4 || ^2.2.3", + "symfony/form": "5.4.*", + "symfony/framework-bundle": "5.4.*", + "symfony/http-client": "5.4.*", + "symfony/intl": "5.4.*", + "symfony/mailer": "5.4.*", + "symfony/messenger": "5.4.*", + "symfony/mime": "5.4.*", + "symfony/monolog-bundle": "^3.1", + "symfony/notifier": "5.4.*", + "symfony/process": "5.4.*", + "symfony/property-access": "5.4.*", + "symfony/property-info": "5.4.*", + "symfony/proxy-manager-bridge": "5.4.*", + "symfony/rate-limiter": "5.4.*", + "symfony/runtime": "5.4.*", + "symfony/security-core": "5.4.*", + "symfony/serializer": "5.4.*", + "symfony/string": "5.4.*", + "symfony/translation": "5.4.*", + "symfony/twig-bundle": "5.4.*", + "symfony/validator": "5.4.*", + "symfony/web-link": "5.4.*", + "symfony/workflow": "5.4.*", + "symfony/yaml": "5.4.*", + "twig/extra-bundle": "^2.12|^3.0", + "twig/intl-extra": "*", + "twig/string-extra": "*", + "twig/twig": "^3.1" + }, + "replace": { + "roadiz/roadiz": "*" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.4", + "phpstan/phpstan": "^1.5.3", + "phpstan/phpstan-doctrine": "^1.3", + "phpunit/phpunit": "^9.5", + "squizlabs/php_codesniffer": "^3.5", + "symfony/browser-kit": "5.4.*", + "symfony/phpunit-bridge": "5.4.*", + "symfony/stopwatch": "5.4.*" + }, + "config": { + "optimize-autoloader": true, + "preferred-install": { + "*": "dist" + }, + "sort-packages": true, + "allow-plugins": { + "symfony/flex": false, + "symfony/runtime": false, + "php-http/discovery": false + } + }, + "autoload": { + "psr-4": { + "RZ\\Roadiz\\CoreBundle\\": "src/" + } + }, + "scripts": { + "auto-scripts": { + "cache:clear": "symfony-cmd", + "assets:install %PUBLIC_DIR%": "symfony-cmd" + } + }, + "extra": { + "branch-alias": { + "dev-main": "2.0.x-dev", + "dev-develop": "2.1.x-dev" + } + } +} diff --git a/lib/RoadizCoreBundle/config/api_resources/attribute.yml b/lib/RoadizCoreBundle/config/api_resources/attribute.yml new file mode 100644 index 00000000..11a3c2d6 --- /dev/null +++ b/lib/RoadizCoreBundle/config/api_resources/attribute.yml @@ -0,0 +1,5 @@ +--- +RZ\Roadiz\CoreBundle\Entity\Attribute: + collectionOperations: [] + itemOperations: [] + diff --git a/lib/RoadizCoreBundle/config/api_resources/attribute_value.yml b/lib/RoadizCoreBundle/config/api_resources/attribute_value.yml new file mode 100644 index 00000000..aba402ae --- /dev/null +++ b/lib/RoadizCoreBundle/config/api_resources/attribute_value.yml @@ -0,0 +1,15 @@ +--- +RZ\Roadiz\CoreBundle\Entity\AttributeValue: + collectionOperations: + get: + method: "GET" + normalization_context: + groups: ["urls", "attribute", "document_display", "attribute_node", "attribute_documents"] + enable_max_depth: true + itemOperations: + get: + method: 'GET' + normalization_context: + groups: ["urls", "attribute", "document_display", "attribute_node", "attribute_documents"] + enable_max_depth: true + diff --git a/lib/RoadizCoreBundle/config/api_resources/custom_form.yml b/lib/RoadizCoreBundle/config/api_resources/custom_form.yml new file mode 100644 index 00000000..a1db8c54 --- /dev/null +++ b/lib/RoadizCoreBundle/config/api_resources/custom_form.yml @@ -0,0 +1,205 @@ +--- +RZ\Roadiz\CoreBundle\Entity\CustomForm: + collectionOperations: + get: + method: "GET" + normalization_context: + enable_max_depth: true + + itemOperations: + get: + method: 'GET' + normalization_context: + enable_max_depth: true + + api_custom_forms_item_post: + method: 'POST' + route_name: api_custom_forms_item_post + normalization_context: + enable_max_depth: true + openapi_context: + summary: Post a user custom form + description: | + Post a user custom form + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + custom_form_slug[email]: + type: string + example: test@test.test + custom_form_slug[first_name]: + type: string + example: John + custom_form_slug[last_name]: + type: string + example: Doe + responses: + 201: ~ + 400: + description: Posted custom form has errors + content: + application/json: + schema: + type: object + properties: + email: + type: object + example: + email: This value is not a valid email address. + 202: + description: Posted custom form was accepted + content: + application/json: + schema: + type: object + properties: { } + + api_custom_forms_item_definition: + method: 'GET' + route_name: api_custom_forms_item_definition + normalization_context: + enable_max_depth: true + openapi_context: + summary: Get a custom form definition for frontend + description: | + Get a custom form definition for frontend + responses: + 200: + description: Custom form definition object + content: + application/json: + schema: + type: object + properties: + title: + type: string + description: Form inputs prefix + example: reiciendis_natus_ducimus_nostrum + type: + type: string + description: Form definition type + example: object + properties: + type: object + description: Form definition fields + example: + email: + type: string + title: Email + attr: + data-group: null + placeholder: null + widget: email + propertyOrder: 1 + first_name: + type: string + title: Firstname + attr: + data-group: null + placeholder: null + widget: string + propertyOrder: 2 + required: + type: array + description: Required fields names + example: + - 'email' + + api_contact_form_post: + method: 'POST' + route_name: api_contact_form_post + normalization_context: + enable_max_depth: true + openapi_context: + summary: Post a user contact form + description: | + Post a user contact form + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + email: + type: string + example: test@test.test + first_name: + type: string + example: John + last_name: + type: string + example: Doe + responses: + 201: ~ + 400: + description: Posted contact form has errors + content: + application/json: + schema: + type: object + properties: + email: + type: object + example: + email: This value is not a valid email address. + 202: + description: Posted contact form was accepted + content: + application/json: + schema: + type: object + properties: { } + + api_contact_form_definition: + method: 'GET' + route_name: api_contact_form_definition + normalization_context: + enable_max_depth: true + openapi_context: + summary: Get a contact form definition for frontend + description: | + Get a contact form definition for frontend + responses: + 200: + description: Contact form definition object + content: + application/json: + schema: + type: object + properties: + title: + type: string + description: Form inputs prefix + example: reiciendis_natus_ducimus_nostrum + type: + type: string + description: Form definition type + example: object + properties: + type: object + description: Form definition fields + example: + email: + type: string + title: Email + attr: + data-group: null + placeholder: null + widget: email + propertyOrder: 1 + first_name: + type: string + title: Firstname + attr: + data-group: null + placeholder: null + widget: string + propertyOrder: 2 + required: + type: array + description: Required fields names + example: + - 'email' diff --git a/lib/RoadizCoreBundle/config/api_resources/document.yml b/lib/RoadizCoreBundle/config/api_resources/document.yml new file mode 100644 index 00000000..9f3f075c --- /dev/null +++ b/lib/RoadizCoreBundle/config/api_resources/document.yml @@ -0,0 +1,15 @@ +--- +RZ\Roadiz\CoreBundle\Entity\Document: + collectionOperations: + get: + method: "GET" + normalization_context: + groups: ["urls", "document_display", "document_display_sources", "position"] + enable_max_depth: true + itemOperations: + get: + method: 'GET' + normalization_context: + groups: ["urls", "document", "document_display", "document_folders", "document_display_sources", "position"] + enable_max_depth: true + diff --git a/lib/RoadizCoreBundle/config/api_resources/folder.yml b/lib/RoadizCoreBundle/config/api_resources/folder.yml new file mode 100644 index 00000000..c29268fd --- /dev/null +++ b/lib/RoadizCoreBundle/config/api_resources/folder.yml @@ -0,0 +1,11 @@ +--- +RZ\Roadiz\CoreBundle\Entity\Folder: + iri: Folder + shortName: Folder + collectionOperations: {} + itemOperations: + get: + method: "GET" + normalization_context: + groups: [ "folder", "position" ] + enable_max_depth: true diff --git a/lib/RoadizCoreBundle/config/api_resources/node.yml b/lib/RoadizCoreBundle/config/api_resources/node.yml new file mode 100644 index 00000000..4a3deb0b --- /dev/null +++ b/lib/RoadizCoreBundle/config/api_resources/node.yml @@ -0,0 +1,10 @@ + +RZ\Roadiz\CoreBundle\Entity\Node: + shortName: Node + collectionOperations: {} + itemOperations: + get: + method: 'GET' + normalization_context: + enable_max_depth: true + groups: ["node", "tag_base", "translation_base", "document_display"] diff --git a/lib/RoadizCoreBundle/config/api_resources/nodes_sources.yml b/lib/RoadizCoreBundle/config/api_resources/nodes_sources.yml new file mode 100644 index 00000000..d6f10d0f --- /dev/null +++ b/lib/RoadizCoreBundle/config/api_resources/nodes_sources.yml @@ -0,0 +1,55 @@ +--- +RZ\Roadiz\CoreBundle\Entity\NodesSources: + iri: NodesSources + shortName: NodesSources + collectionOperations: + # Get operation is needed for sitemap generation + get: + method: "GET" + normalization_context: + enable_max_depth: true + groups: + - nodes_sources_base + - nodes_sources_default + - urls + - tag_base + - translation_base + - document_display + - position +# search: +# method: 'GET' +# path: '/nodes_sources/search' +# controller: App\Controller\SearchNodesSourcesController +# normalization_context: +# groups: +# - nodes_sources_base +# - nodes_sources_default +# - urls +# - tag_base +# - translation_base +# - document_display +# - position +# openapi_context: +# summary: Search NodesSources resources +# description: | +# Search NodesSources resources using **Solr** full-text search engine +# parameters: +# - type: string +# name: search +# in: query +# required: true +# description: Search pattern +# schema: +# type: string + + itemOperations: + get: + method: 'GET' + normalization_context: + enable_max_depth: true + groups: + - nodes_sources + - urls + - tag_base + - translation_base + - document_display diff --git a/lib/RoadizCoreBundle/config/api_resources/realm.yml b/lib/RoadizCoreBundle/config/api_resources/realm.yml new file mode 100644 index 00000000..45da623a --- /dev/null +++ b/lib/RoadizCoreBundle/config/api_resources/realm.yml @@ -0,0 +1,11 @@ +--- +RZ\Roadiz\CoreBundle\Entity\Realm: + iri: Realm + shortName: Realm + collectionOperations: {} + itemOperations: + get: + method: "GET" + normalization_context: + groups: [ "get", "realm" ] + enable_max_depth: true diff --git a/lib/RoadizCoreBundle/config/api_resources/tag.yml b/lib/RoadizCoreBundle/config/api_resources/tag.yml new file mode 100644 index 00000000..dc516bb0 --- /dev/null +++ b/lib/RoadizCoreBundle/config/api_resources/tag.yml @@ -0,0 +1,20 @@ + +RZ\Roadiz\CoreBundle\Entity\Tag: + iri: Tag + shortName: Tag + collectionOperations: + get: + method: "GET" + normalization_context: + enable_max_depth: true + groups: + - tag + - tag_base + itemOperations: + get: + method: 'GET' + normalization_context: + enable_max_depth: true + groups: + - tag + - tag_base diff --git a/lib/RoadizCoreBundle/config/api_resources/translation.yml b/lib/RoadizCoreBundle/config/api_resources/translation.yml new file mode 100644 index 00000000..304ffa9e --- /dev/null +++ b/lib/RoadizCoreBundle/config/api_resources/translation.yml @@ -0,0 +1,9 @@ + +RZ\Roadiz\CoreBundle\Entity\Translation: + collectionOperations: + get: + method: "GET" + itemOperations: + get: + method: 'GET' + diff --git a/lib/RoadizCoreBundle/config/api_resources/web_response.yml b/lib/RoadizCoreBundle/config/api_resources/web_response.yml new file mode 100644 index 00000000..2353a4c1 --- /dev/null +++ b/lib/RoadizCoreBundle/config/api_resources/web_response.yml @@ -0,0 +1,39 @@ +RZ\Roadiz\CoreBundle\Api\Model\WebResponse: + collectionOperations: {} + itemOperations: + getByPath: + method: 'GET' + path: '/web_response_by_path' + read: false + controller: RZ\Roadiz\CoreBundle\Api\Controller\GetWebResponseByPathController + pagination_enabled: false + normalization_context: + enable_max_depth: true + pagination_enabled: false + groups: + - get + - web_response + - position + - walker + - walker_level + - meta + - children + - children_count + - nodes_sources + - urls + - tag_base + - translation_base + - document_display + - node_attributes + openapi_context: + summary: Get a resource by its path wrapped in a WebResponse object + description: | + Get a resource by its path wrapped in a WebResponse + parameters: + - type: string + name: path + in: query + required: true + description: Resource path, or `/` for home page + schema: + type: string diff --git a/lib/RoadizCoreBundle/config/fixtures.yaml b/lib/RoadizCoreBundle/config/fixtures.yaml new file mode 100644 index 00000000..037cdfed --- /dev/null +++ b/lib/RoadizCoreBundle/config/fixtures.yaml @@ -0,0 +1,7 @@ +importFiles: + roles: + - fixtures/roles.json + groups: [] + settings: + - fixtures/settings.json + nodetypes: [] diff --git a/lib/RoadizCoreBundle/config/fixtures/roles.json b/lib/RoadizCoreBundle/config/fixtures/roles.json new file mode 100644 index 00000000..3b468365 --- /dev/null +++ b/lib/RoadizCoreBundle/config/fixtures/roles.json @@ -0,0 +1,249 @@ +[ + { + "name": "ROLE_ACCESS_VERSIONS", + "groups": [ + { + "name": "Editors" + } + ] + }, + { + "name": "ROLE_ACCESS_ATTRIBUTES", + "groups": [ + { + "name": "Editors" + } + ] + }, + { + "name": "ROLE_ACCESS_ATTRIBUTES_DELETE", + "groups": [ + { + "name": "Editors" + } + ] + }, + { + "name": "ROLE_ACCESS_CUSTOMFORMS", + "groups": [ + { + "name": "Editors" + } + ] + }, + { + "name": "ROLE_ACCESS_CUSTOMFORMS_RETENTION", + "groups": [ + { + "name": "Editors" + } + ] + }, + { + "name": "ROLE_ACCESS_CUSTOMFORMS_DELETE", + "groups": [ + { + "name": "Editors" + } + ] + }, + { + "name": "ROLE_ACCESS_DOCTRINE_CACHE_DELETE", + "groups": [ + { + "name": "Editors" + } + ] + }, + { + "name": "ROLE_ACCESS_DOCUMENTS", + "groups": [ + { + "name": "Editors" + } + ] + }, + { + "name": "ROLE_ACCESS_DOCUMENTS_LIMITATIONS", + "groups": [ + { + "name": "Editors" + } + ] + }, + { + "name": "ROLE_ACCESS_DOCUMENTS_DELETE", + "groups": [ + { + "name": "Editors" + } + ] + }, + { + "name": "ROLE_ACCESS_DOCUMENTS_CREATION_DATE", + "groups": [ + { + "name": "Admin" + } + ] + }, + { + "name": "ROLE_ACCESS_GROUPS", + "groups": [] + }, + { + "name": "ROLE_ACCESS_NODE_ATTRIBUTES", + "groups": [ + { + "name": "Editors" + } + ] + }, + { + "name": "ROLE_ACCESS_NODEFIELDS_DELETE", + "groups": [] + }, + { + "name": "ROLE_ACCESS_NODES", + "groups": [ + { + "name": "Editors" + } + ] + }, + { + "name": "ROLE_ACCESS_NODES_DELETE", + "groups": [ + { + "name": "Editors" + } + ] + }, + { + "name": "ROLE_ACCESS_NODES_SETTING", + "groups": [ + { + "name": "Editors" + } + ] + }, + { + "name": "ROLE_ACCESS_NODES_STATUS", + "groups": [ + { + "name": "Editors" + } + ] + }, + { + "name": "ROLE_ACCESS_NODETYPES", + "groups": [] + }, + { + "name": "ROLE_ACCESS_NODETYPES_DELETE", + "groups": [] + }, + { + "name": "ROLE_ACCESS_REDIRECTIONS", + "groups": [ + { + "name": "Editors" + } + ] + }, + { + "name": "ROLE_ACCESS_ROLES", + "groups": [] + }, + { + "name": "ROLE_ACCESS_SETTINGS", + "groups": [] + }, + { + "name": "ROLE_ACCESS_TAGS", + "groups": [ + { + "name": "Editors" + } + ] + }, + { + "name": "ROLE_ACCESS_TAGS_DELETE", + "groups": [ + { + "name": "Editors" + } + ] + }, + { + "name": "ROLE_ACCESS_THEMES", + "groups": [] + }, + { + "name": "ROLE_ACCESS_TRANSLATIONS", + "groups": [] + }, + { + "name": "ROLE_ACCESS_USERS", + "groups": [] + }, + { + "name": "ROLE_ACCESS_USERS_DELETE", + "groups": [] + }, + { + "name": "ROLE_ACCESS_WEBHOOKS", + "groups": [] + }, + { + "name": "ROLE_BACKEND_USER", + "groups": [ + { + "name": "Backend User" + }, + { + "name": "Editors" + } + ] + }, + { + "name": "ROLE_SUPERADMIN", + "groups": [ + { + "name": "Admin" + } + ] + }, + { + "name": "ROLE_ACCESS_LOGS", + "groups": [ + { + "name": "Admin" + } + ] + }, + { + "name": "ROLE_ACCESS_REALMS", + "groups": [ + { + "name": "Admin" + } + ] + }, + { + "name": "ROLE_ACCESS_REALM_NODES", + "groups": [ + { + "name": "Admin" + } + ] + }, + { + "name": "ROLE_ALLOWED_TO_SWITCH", + "groups": [ + { + "name": "Admin" + } + ] + } +] diff --git a/lib/RoadizCoreBundle/config/fixtures/settings.json b/lib/RoadizCoreBundle/config/fixtures/settings.json new file mode 100644 index 00000000..137890ad --- /dev/null +++ b/lib/RoadizCoreBundle/config/fixtures/settings.json @@ -0,0 +1,308 @@ +[ + { + "name": "force_locale", + "visible": true, + "encrypted": false, + "description": "Force displaying translation locale in every node’ paths. This should be *ON* if you redirect users based on their language on homepage.", + "setting_group": { + "name": "Development", + "in_menu": true + }, + "type": 5, + "default_values": "" + }, + { + "name": "force_locale_with_urlaliases", + "description": "force_locale_with_urlaliases.help", + "visible": true, + "encrypted": false, + "setting_group": { + "name": "Development", + "in_menu": true + }, + "type": 5 + }, + { + "name": "leaflet_map_tile_url", + "value": "https:\/\/{s}.tile.osm.org\/{z}\/{x}\/{y}.png", + "encrypted": false, + "description": "Default maps tiles layout when using *Leaflet*.", + "visible": true, + "setting_group": { + "name": "Development", + "in_menu": true + }, + "type": 0, + "default_values": "" + }, + { + "name": "maps_default_location", + "value": "{\"lat\":45.769785, \"lng\":4.833967, \"zoom\":14}", + "encrypted": false, + "description": "Default maps marker location.", + "visible": true, + "setting_group": { + "name": "Development", + "in_menu": true + }, + "type": 23, + "default_values": "" + }, + { + "name": "openid_button_label", + "description": "openid_button_label.help", + "visible": true, + "encrypted": false, + "setting_group": { + "name": "OpenId", + "in_menu": true + }, + "type": 0 + }, + { + "name": "support_email_address", + "visible": true, + "encrypted": false, + "description": "Support email address, used in every system emails footer", + "setting_group": { + "name": "Emailings", + "in_menu": true + }, + "type": 8, + "default_values": "" + }, + { + "name": "email_sender", + "visible": true, + "encrypted": false, + "description": "Default sender email, used as origin for every system email sent. This email **must be allowed by your SMTP server.**", + "setting_group": { + "name": "Emailings", + "in_menu": true + }, + "type": 8, + "default_values": "" + }, + { + "name": "email_sender_name", + "visible": true, + "encrypted": false, + "setting_group": { + "name": "Emailings", + "in_menu": true + }, + "type": 0, + "default_values": "" + }, + { + "name": "universal_analytics_id", + "visible": true, + "encrypted": false, + "setting_group": { + "name": "APIs", + "in_menu": true + }, + "type": 0, + "default_values": "" + }, + { + "name": "google_tag_manager_id", + "visible": true, + "encrypted": false, + "setting_group": { + "name": "APIs", + "in_menu": true + }, + "type": 0, + "default_values": "" + }, + { + "name": "instagram_access_token", + "visible": true, + "encrypted": true, + "setting_group": { + "name": "APIs", + "in_menu": true + }, + "type": 0, + "default_values": "" + }, + { + "name": "seo_description", + "visible": true, + "encrypted": false, + "setting_group": { + "name": "Site information", + "in_menu": true + }, + "type": 2, + "default_values": "" + }, + { + "name": "site_name", + "visible": true, + "encrypted": false, + "setting_group": { + "name": "Site information", + "in_menu": true + }, + "type": 0, + "default_values": "" + }, + { + "name": "maintenance_mode", + "visible": true, + "encrypted": false, + "description": "Switch maintenance mode. Only login page will be available for public requests.", + "setting_group": { + "name": "Site information", + "in_menu": true + }, + "type": 5, + "default_values": "" + }, + { + "name": "site_copyright", + "visible": true, + "encrypted": false, + "setting_group": { + "name": "Site information", + "in_menu": true + }, + "type": 0, + "default_values": "" + }, + { + "name": "main_color", + "encrypted": false, + "visible": true, + "setting_group": { + "name": "Site information", + "in_menu": true + }, + "type": 11, + "default_values": "" + }, + { + "name": "admin_image", + "visible": true, + "encrypted": false, + "setting_group": { + "name": "Site information", + "in_menu": true + }, + "type": 9, + "default_values": "" + }, + { + "name": "login_image", + "visible": true, + "encrypted": false, + "description": "Replace random *Splashbase* login images with your own.", + "setting_group": { + "name": "Site information", + "in_menu": true + }, + "type": 9, + "default_values": "" + }, + { + "name": "facebook_url", + "visible": true, + "encrypted": false, + "setting_group": { + "name": "Social networks", + "in_menu": true + }, + "type": 0, + "default_values": "" + }, + { + "name": "instagram_url", + "visible": true, + "encrypted": false, + "setting_group": { + "name": "Social networks", + "in_menu": true + }, + "type": 0, + "default_values": "" + }, + { + "name": "pinterest_url", + "visible": true, + "encrypted": false, + "setting_group": { + "name": "Social networks", + "in_menu": true + }, + "type": 0, + "default_values": "" + }, + { + "name": "twitter_url", + "visible": true, + "encrypted": false, + "setting_group": { + "name": "Social networks", + "in_menu": true + }, + "type": 0, + "default_values": "" + }, + { + "name": "linkedin_url", + "visible": true, + "encrypted": false, + "setting_group": { + "name": "Social networks", + "in_menu": true + }, + "type": 0, + "default_values": "" + }, + { + "name": "youtube_url", + "visible": true, + "encrypted": false, + "setting_group": { + "name": "Social networks", + "in_menu": true + }, + "type": 0, + "default_values": "" + }, + { + "name": "custom_preview_scheme", + "description": "custom_preview_scheme.help", + "visible": true, + "encrypted": false, + "setting_group": { + "name": "Site information", + "in_menu": true + }, + "type": 0 + }, + { + "name": "custom_public_scheme", + "description": "custom_public_scheme.help", + "visible": true, + "encrypted": false, + "setting_group": { + "name": "Site information", + "in_menu": true + }, + "type": 0 + }, + { + "name": "dashboard_iframe", + "description": "dashboard_iframe.help", + "visible": true, + "encrypted": false, + "setting_group": { + "name": "Site information", + "in_menu": true + }, + "type": 2 + } +] diff --git a/lib/RoadizCoreBundle/config/packages/api_platform.yaml b/lib/RoadizCoreBundle/config/packages/api_platform.yaml new file mode 100644 index 00000000..0fc9e246 --- /dev/null +++ b/lib/RoadizCoreBundle/config/packages/api_platform.yaml @@ -0,0 +1,50 @@ +api_platform: + patch_formats: + json: ['application/merge-patch+json'] + enable_swagger_ui: false + enable_re_doc: true + graphql: + graphiql: + enabled: false + show_webby: false + swagger: + versions: [3] + title: "My Roadiz website API" + description: "My Roadiz website API" + version: '%env(string:APP_VERSION)%' + mapping: + paths: + - '%kernel.project_dir%/src/Entity' + - '%kernel.project_dir%/src/GeneratedEntity' + - '%kernel.project_dir%/vendor/roadiz/core-bundle/src/Entity' + - '%kernel.project_dir%/vendor/rezozero/tree-walker/src' + - '%kernel.project_dir%/config/api_resources' +# http_cache: +# # Automatically generate etags for API responses. +# etag: true +# public: true +# # Default value for the response max age. +# max_age: '%env(int:HTTP_CACHE_MAX_AGE)%' +# # Default value for the response shared (proxy) max age. +# shared_max_age: '%env(int:HTTP_CACHE_SHARED_MAX_AGE)%' +# # Default values of the "Vary" HTTP header. +# vary: ['Accept', 'Authorization', 'Origin', 'Accept-Encoding', 'Content-Type'] +# invalidation: +# enabled: true +# varnish_urls: ['%env(VARNISH_URL)%'] + defaults: + pagination_client_items_per_page: true + pagination_items_per_page: 15 + pagination_maximum_items_per_page: 50 +# cache_headers: +# etag: true +# public: true +# max_age: '%env(int:HTTP_CACHE_MAX_AGE)%' +# shared_max_age: '%env(int:HTTP_CACHE_SHARED_MAX_AGE)%' +# vary: ['Accept', 'Authorization', 'Origin', 'Accept-Encoding', 'Content-Type'] + collection: + pagination: + items_per_page: 15 # Default value + maximum_items_per_page: 50 + client_items_per_page: true + items_per_page_parameter_name: itemsPerPage diff --git a/lib/RoadizCoreBundle/config/packages/doctrine.yaml b/lib/RoadizCoreBundle/config/packages/doctrine.yaml new file mode 100644 index 00000000..2a964139 --- /dev/null +++ b/lib/RoadizCoreBundle/config/packages/doctrine.yaml @@ -0,0 +1,53 @@ +doctrine: + dbal: + url: '%env(resolve:DATABASE_URL)%' + # IMPORTANT: You MUST configure your server version, + # either here or in the DATABASE_URL env var (see .env file) + #server_version: '13' + orm: + dql: + string_functions: + JSON_CONTAINS: Scienta\DoctrineJsonFunctions\Query\AST\Functions\Mysql\JsonContains + auto_generate_proxy_classes: true + naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware + auto_mapping: true + mappings: + App: + is_bundle: false + type: annotation + dir: '%kernel.project_dir%/src/Entity' + prefix: 'App\Entity' + alias: App + RoadizCoreBundle: + is_bundle: true + type: annotation + dir: 'src/Entity' + prefix: 'RZ\Roadiz\CoreBundle\Entity' + alias: RoadizCoreBundle + RZ\Roadiz\Core: + is_bundle: false + type: annotation + dir: '%kernel.project_dir%/vendor/roadiz/models/src/Roadiz/Core/AbstractEntities' + prefix: 'RZ\Roadiz\Core\AbstractEntities' + alias: AbstractEntities + App\GeneratedEntity: + is_bundle: false + type: annotation + dir: '%kernel.project_dir%/src/GeneratedEntity' + prefix: 'App\GeneratedEntity' + alias: App\GeneratedEntity + + resolve_target_entities: + Symfony\Component\Security\Core\User\UserInterface: RZ\Roadiz\CoreBundle\Entity\User + + # Roadiz Core + RZ\Roadiz\Documents\Models\DocumentInterface: RZ\Roadiz\CoreBundle\Entity\Document + RZ\Roadiz\Documents\Models\FolderInterface: RZ\Roadiz\CoreBundle\Entity\Folder + RZ\Roadiz\Contracts\NodeType\NodeTypeInterface: RZ\Roadiz\CoreBundle\Entity\NodeType + RZ\Roadiz\CoreBundle\Model\AttributeInterface: RZ\Roadiz\CoreBundle\Entity\Attribute + RZ\Roadiz\CoreBundle\Model\AttributeTranslationInterface: RZ\Roadiz\CoreBundle\Entity\AttributeTranslation + RZ\Roadiz\CoreBundle\Model\AttributeGroupInterface: RZ\Roadiz\CoreBundle\Entity\AttributeGroup + RZ\Roadiz\CoreBundle\Model\AttributeGroupTranslationInterface: RZ\Roadiz\CoreBundle\Entity\AttributeGroupTranslation + RZ\Roadiz\CoreBundle\Model\AttributeValueInterface: RZ\Roadiz\CoreBundle\Entity\AttributeValue + RZ\Roadiz\CoreBundle\Model\AttributeValueTranslationInterface: RZ\Roadiz\CoreBundle\Entity\AttributeValueTranslation + RZ\Roadiz\Core\AbstractEntities\TranslationInterface: RZ\Roadiz\CoreBundle\Entity\Translation diff --git a/lib/RoadizCoreBundle/config/packages/flysystem.yaml b/lib/RoadizCoreBundle/config/packages/flysystem.yaml new file mode 100644 index 00000000..b52d8a07 --- /dev/null +++ b/lib/RoadizCoreBundle/config/packages/flysystem.yaml @@ -0,0 +1,51 @@ +# Read the documentation at https://github.com/thephpleague/flysystem-bundle/blob/master/docs/1-getting-started.md +flysystem: + storages: + intervention_request.storage: + adapter: 'local' + options: + directory: '%kernel.project_dir%/public/files' + + +# Example for using AWS S3 compatible storage + +#services: +# scaleway_public_client: +# class: 'AsyncAws\SimpleS3\SimpleS3Client' +# arguments: +# - endpoint: '%env(SCALEWAY_STORAGE_ENDPOINT)%' +# accessKeyId: '%env(SCALEWAY_STORAGE_ID)%' +# accessKeySecret: '%env(SCALEWAY_STORAGE_SECRET)%' +# region: '%env(SCALEWAY_STORAGE_REGION)%' +# # Private client must be different for allowing copy across file systems. +# scaleway_private_client: +# class: 'AsyncAws\SimpleS3\SimpleS3Client' +# arguments: +# - endpoint: '%env(SCALEWAY_STORAGE_ENDPOINT)%' +# accessKeyId: '%env(SCALEWAY_STORAGE_ID)%' +# accessKeySecret: '%env(SCALEWAY_STORAGE_SECRET)%' +# region: '%env(SCALEWAY_STORAGE_REGION)%' +# +#flysystem: +# storages: +# documents_public.storage: +# adapter: 'asyncaws' +# visibility: 'public' +# options: +# client: 'scaleway_public_client' +# bucket: '%env(SCALEWAY_STORAGE_BUCKET)%' +# prefix: 'testing-public-files' +# documents_private.storage: +# adapter: 'asyncaws' +# visibility: 'private' +# options: +# client: 'scaleway_private_client' +# bucket: '%env(SCALEWAY_STORAGE_BUCKET)%' +# prefix: 'testing-private-files' +# intervention_request.storage: +# adapter: 'asyncaws' +# visibility: 'public' +# options: +# client: 'scaleway_public_client' +# bucket: '%env(SCALEWAY_STORAGE_BUCKET)%' +# prefix: 'testing-public-files' diff --git a/lib/RoadizCoreBundle/config/packages/framework.yaml b/lib/RoadizCoreBundle/config/packages/framework.yaml new file mode 100644 index 00000000..ed735a18 --- /dev/null +++ b/lib/RoadizCoreBundle/config/packages/framework.yaml @@ -0,0 +1,51 @@ +# see https://symfony.com/doc/current/reference/configuration/framework.html +framework: + secret: '%env(APP_SECRET)%' + trusted_proxies: '%env(TRUSTED_PROXIES)%' + #csrf_protection: true + http_method_override: false + + # Enables session support. Note that the session will ONLY be started if you read or write from it. + # Remove or comment this section to explicitly disable session support. + session: + handler_id: null + cookie_secure: auto + cookie_samesite: lax + storage_factory_id: session.storage.factory.native + + #esi: true + #fragments: true + php_errors: + log: true + serializer: + circular_reference_handler: 'RZ\Roadiz\CoreBundle\Serializer\CircularReferenceHandler' + max_depth_handler: 'RZ\Roadiz\CoreBundle\Serializer\CircularReferenceHandler' + csrf_protection: + enabled: true + + rate_limiter: + contact_form: + policy: 'token_bucket' + limit: 10 + rate: { interval: '1 minutes', amount: 5 } + cache_pool: 'cache.contact_form_limiter' + newsletter_form: + policy: 'token_bucket' + limit: 5 + rate: { interval: '1 minutes', amount: 5 } + cache_pool: 'cache.newsletter_form_limiter' + password_protected: + policy: 'token_bucket' + limit: 3 + rate: { interval: '1 minutes', amount: 3 } + cache_pool: 'cache.password_protected_limiter' + custom_form: + policy: 'token_bucket' + limit: 10 + rate: { interval: '1 minutes', amount: 5 } + cache_pool: 'cache.custom_form_limiter' +when@test: + framework: + test: true + session: + storage_factory_id: session.storage.factory.mock_file diff --git a/lib/RoadizCoreBundle/config/packages/jms_serializer.yaml b/lib/RoadizCoreBundle/config/packages/jms_serializer.yaml new file mode 100644 index 00000000..d25fa039 --- /dev/null +++ b/lib/RoadizCoreBundle/config/packages/jms_serializer.yaml @@ -0,0 +1,13 @@ +jms_serializer: + visitors: + xml_serialization: + format_output: '%kernel.debug%' +# metadata: +# auto_detection: false +# directories: +# any-name: +# namespace_prefix: "My\\FooBundle" +# path: "@MyFooBundle/Resources/config/serializer" +# another-name: +# namespace_prefix: "My\\BarBundle" +# path: "@MyBarBundle/Resources/config/serializer" diff --git a/lib/RoadizCoreBundle/config/packages/messenger.yaml b/lib/RoadizCoreBundle/config/packages/messenger.yaml new file mode 100644 index 00000000..5d8feaed --- /dev/null +++ b/lib/RoadizCoreBundle/config/packages/messenger.yaml @@ -0,0 +1,14 @@ +framework: + messenger: + # Uncomment this (and the failed transport below) to send failed messages to this transport for later handling. + # failure_transport: failed + + transports: + # https://symfony.com/doc/current/messenger.html#transport-configuration + async: '%env(MESSENGER_TRANSPORT_DSN)%' + failed: 'doctrine://default?queue_name=failed' + sync: 'sync://' + + routing: + # Route your messages to the transports + 'RZ\Roadiz\CoreBundle\Message\AsyncMessage': async diff --git a/lib/RoadizCoreBundle/config/packages/monolog.yaml b/lib/RoadizCoreBundle/config/packages/monolog.yaml new file mode 100644 index 00000000..c3c5e9e6 --- /dev/null +++ b/lib/RoadizCoreBundle/config/packages/monolog.yaml @@ -0,0 +1,30 @@ +monolog: + handlers: + main: + type: stream + path: "%kernel.logs_dir%/%kernel.environment%.log" + level: debug + channels: ["!event"] + solr: + type: stream + path: "%kernel.logs_dir%/solr.%kernel.environment%.log" + level: debug + channels: ["searchEngine"] + # uncomment to get logging in your browser + # you may have to allow bigger header sizes in your Web server configuration + #firephp: + # type: firephp + # level: info + #chromephp: + # type: chromephp + # level: info + console: + type: console + process_psr_3_messages: false + channels: ["!event", "!doctrine", "!console"] + + custom: + type: service + id: RZ\Roadiz\CoreBundle\Logger\DoctrineHandler + level: info + channels: [ "app", "security" ] diff --git a/lib/RoadizCoreBundle/config/packages/roadiz_core.yaml b/lib/RoadizCoreBundle/config/packages/roadiz_core.yaml new file mode 100644 index 00000000..818584c3 --- /dev/null +++ b/lib/RoadizCoreBundle/config/packages/roadiz_core.yaml @@ -0,0 +1,41 @@ +roadiz_core: + appNamespace: '%env(string:APP_NAMESPACE)%' + appVersion: '%env(string:APP_VERSION)%' + healthCheckToken: '%env(string:APP_HEALTH_CHECK_TOKEN)%' + staticDomainName: ~ + defaultNodeSourceController: App\Controller\NullController + useNativeJsonColumnType: true + hideRoadizVersion: false + # When no information to find locale is found and "force_locale" setting is ON, + # Roadiz can find root path translation based on Accept-Language header. + # Be careful if you are using a reverse-proxy cache, YOU MUST vary on Accept-Language header and normalize it. + # @see https://varnish-cache.org/docs/6.3/users-guide/increasing-your-hitrate.html#http-vary + useAcceptLanguageHeader: '%env(bool:APP_USE_ACCEPT_LANGUAGE_HEADER)%' + security: + private_key_name: default + themes: [] + medias: + unsplash_client_id: '%env(string:APP_UNSPLASH_CLIENT_ID)%' + soundcloud_client_id: '%env(string:APP_SOUNDCLOUD_CLIENT_ID)%' + google_server_id: '%env(string:APP_GOOGLE_SERVER_ID)%' + recaptcha_private_key: '%env(string:APP_RECAPTCHA_PRIVATE_KEY)%' + recaptcha_public_key: '%env(string:APP_RECAPTCHA_PUBLIC_KEY)%' + ffmpeg_path: '%env(string:APP_FFMPEG_PATH)%' + inheritance: + type: single_table + reverseProxyCache: + frontend: + default: + host: '%env(string:VARNISH_HOST)%' + domainName: '%env(string:VARNISH_DOMAIN)%' + solr: + timeout: 3 + endpoints: + docker: + host: '%env(string:SOLR_HOST)%' + core: '%env(string:SOLR_CORE_NAME)%' + port: '%env(string:SOLR_PORT)%' + path: / + + + diff --git a/lib/RoadizCoreBundle/config/packages/security.yaml b/lib/RoadizCoreBundle/config/packages/security.yaml new file mode 100644 index 00000000..00ef8080 --- /dev/null +++ b/lib/RoadizCoreBundle/config/packages/security.yaml @@ -0,0 +1,78 @@ +security: + # https://symfony.com/doc/current/security/experimental_authenticators.html + enable_authenticator_manager: true + # https://symfony.com/doc/current/security.html#c-hashing-passwords + password_hashers: + Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: + algorithm: auto + + # https://symfony.com/doc/current/security.html#where-do-users-come-from-user-providers + providers: + roadiz_user_provider: + entity: + class: RZ\Roadiz\CoreBundle\Entity\User + property: username + all_users: + chain: + providers: [ 'roadiz_user_provider' ] + + firewalls: + dev: + pattern: ^/(_(profiler|wdt)|css|images|js)/ + security: false + # JWT for API + api: + pattern: ^/api + stateless: true + provider: all_users + login_throttling: + max_attempts: 3 + json_login: + check_path: /api/token + username_path: username + password_path: password + success_handler: lexik_jwt_authentication.handler.authentication_success + failure_handler: lexik_jwt_authentication.handler.authentication_failure + jwt: ~ + # disables session creation for assets and healthcheck controllers + assets: + pattern: ^/assets + stateless: true + security: false + healthCheck: + pattern: ^/health-check$ + stateless: true + security: false + main: + lazy: true + provider: all_users + + # activate different ways to authenticate + # https://symfony.com/doc/current/security.html#firewalls-authentication + + # https://symfony.com/doc/current/security/impersonating_user.html + switch_user: { role: ROLE_SUPERADMIN, parameter: _su } + entry_point: RZ\Roadiz\RozierBundle\Security\RozierAuthenticator + remember_me: + secret: '%kernel.secret%' + lifetime: 604800 # 1 week in seconds + path: / + login_throttling: + max_attempts: 3 + logout: + path: logoutPage + guard: + authenticators: + - lexik_jwt_authentication.jwt_token_authenticator + custom_authenticator: + - RZ\Roadiz\RozierBundle\Security\RozierAuthenticator + + # Easy way to control access for large sections of your site + # Note: Only the *first* access control that matches will be used + access_control: + - { path: ^/rz-admin/login, roles: IS_AUTHENTICATED_ANONYMOUSLY } + - { path: ^/rz-admin, roles: ROLE_BACKEND_USER } + - { path: ^/api/token, roles: IS_AUTHENTICATED_ANONYMOUSLY } + - { path: "^/api/custom_forms/(?:[0-9]+)/post", methods: [ POST ], roles: IS_AUTHENTICATED_ANONYMOUSLY } + - { path: ^/api, roles: ROLE_BACKEND_USER, methods: [ POST, PUT, PATCH, DELETE ] } + # - { path: ^/profile, roles: ROLE_USER } diff --git a/lib/RoadizCoreBundle/config/packages/twig.yaml b/lib/RoadizCoreBundle/config/packages/twig.yaml new file mode 100644 index 00000000..24daf0d5 --- /dev/null +++ b/lib/RoadizCoreBundle/config/packages/twig.yaml @@ -0,0 +1,7 @@ +twig: + default_path: '%kernel.project_dir%/templates' + strict_variables: false + +when@test: + twig: + strict_variables: true diff --git a/lib/RoadizCoreBundle/config/routing.yaml b/lib/RoadizCoreBundle/config/routing.yaml new file mode 100644 index 00000000..361fab01 --- /dev/null +++ b/lib/RoadizCoreBundle/config/routing.yaml @@ -0,0 +1,39 @@ +# +# Custom forms +# +api_custom_forms_item_definition: + methods: [GET] + path: /api/custom_forms/{id}/definition + controller: RZ\Roadiz\CoreBundle\Controller\CustomFormController::definitionAction + requirements: + id: "[0-9]+" +api_custom_forms_item_post: + methods: [POST] + path: /api/custom_forms/{id}/post + controller: RZ\Roadiz\CoreBundle\Controller\CustomFormController::postAction + requirements: + id: "[0-9]+" + +customFormSendAction: + path: /custom-form/{customFormId} + controller: RZ\Roadiz\CoreBundle\Controller\CustomFormController::addAction + requirements: + customFormId: "[0-9]+" +customFormSentAction: + path: /custom-form/{customFormId}/sent + controller: RZ\Roadiz\CoreBundle\Controller\CustomFormController::sentAction + requirements: + customFormId: "[0-9]+" + +healthCheckAction: + methods: [GET] + path: /health-check + controller: RZ\Roadiz\CoreBundle\Controller\HealthCheckController + +roadiz_core_themes: + resource: . + type: themes + +api_login_check: + methods: [POST] + path: /api/token diff --git a/lib/RoadizCoreBundle/config/services.yaml b/lib/RoadizCoreBundle/config/services.yaml new file mode 100644 index 00000000..c16e712e --- /dev/null +++ b/lib/RoadizCoreBundle/config/services.yaml @@ -0,0 +1,610 @@ +--- +parameters: + env(APP_NAMESPACE): "roadiz" + env(APP_VERSION): "0.1.0" + env(APP_USE_ACCEPT_LANGUAGE_HEADER): 'false' + env(APP_UNSPLASH_CLIENT_ID): ~ + env(APP_SOUNDCLOUD_CLIENT_ID): ~ + env(APP_RECAPTCHA_PRIVATE_KEY): ~ + env(APP_RECAPTCHA_PUBLIC_KEY): ~ + env(APP_GOOGLE_SERVER_ID): ~ + env(APP_HEALTH_CHECK_TOKEN): ~ + env(OPEN_ID_CLIENT_ID): ~ + env(OPEN_ID_CLIENT_SECRET): ~ + env(OPEN_ID_HOSTED_DOMAIN): ~ + env(OPEN_ID_DISCOVERY_URL): ~ + env(SOLR_CORE_NAME): "roadiz" + env(SOLR_PORT): "8983" + +services: + # default configuration for services in *this* file + _defaults: + autowire: true # Automatically injects dependencies in your services. + autoconfigure: true # Automatically registers your services as commands, event subscribers, etc. + bind: + $cmsVersion: '2.1.x-dev' + $appVersion: '%roadiz_core.app_version%' + $cmsVersionPrefix: 'develop' + $staticDomain: '%roadiz_core.static_domain_name%' + $hideRoadizVersion: '%roadiz_core.hide_roadiz_version%' + $inheritanceType: '%roadiz_core.inheritance_type%' + $maxPixelSize: '%rz_intervention_request.max_pixel_size%' + $appNamespace: '%roadiz_core.app_namespace%' + $projectDir: '%kernel.project_dir%' + $exportDir: '%kernel.project_dir%/var/export' + $privateKeyName: '%roadiz_core.private_key_name%' + $generatedEntitiesDir: '%roadiz_core.generated_entities_dir%' + $serializedNodeTypesDir: '%roadiz_core.serialized_node_types_dir%' + $importFilesConfigPath: '%roadiz_core.import_files_config_path%' + $kernelProjectDir: '%kernel.project_dir%' + $apiResourcesDir: '%kernel.project_dir%/config/api_resources' + $debug: '%kernel.debug%' + $defaultControllerClass: '%roadiz_core.default_node_source_controller%' + $webhookMessageTypes: '%roadiz_core.webhook.message_types%' + $useAcceptLanguageHeader: '%roadiz_core.use_accept_language_header%' + $healthCheckToken: '%roadiz_core.health_check_token%' + $ffmpegPath: '%roadiz_core.medias.ffmpeg_path%' + $useTypedNodeNames: '%roadiz_core.use_typed_node_names%' + $maxVersionsShowed: '%roadiz_core.max_versions_showed%' + $recaptchaPublicKey: '%roadiz_core.medias.recaptcha_public_key%' + $recaptchaPrivateKey: '%roadiz_core.medias.recaptcha_private_key%' + + RZ\Roadiz\CoreBundle\: + resource: '../src/' + exclude: + - '../src/DependencyInjection/' + - '../src/Entity/' + - '../src/Traits/' + - '../src/Kernel.php' + - '../src/Tests/' + - '../src/Event/' + + RZ\Roadiz\CoreBundle\EntityHandler\: + resource: '../src/EntityHandler/' + # Recreate handlers for each usage + shared: false + public: true + + RZ\Roadiz\CoreBundle\Document\MediaFinder\YoutubeEmbedFinder: + tags: [ { name: 'roadiz_core.media_finder', platform: 'youtube' } ] + RZ\Roadiz\CoreBundle\Document\MediaFinder\VimeoEmbedFinder: + tags: [ { name: 'roadiz_core.media_finder', platform: 'vimeo' } ] + RZ\Roadiz\CoreBundle\Document\MediaFinder\DeezerEmbedFinder: + tags: [ { name: 'roadiz_core.media_finder', platform: 'deezer' } ] + RZ\Roadiz\CoreBundle\Document\MediaFinder\DailymotionEmbedFinder: + tags: [ { name: 'roadiz_core.media_finder', platform: 'dailymotion' } ] + RZ\Roadiz\CoreBundle\Document\MediaFinder\SoundcloudEmbedFinder: + tags: [ { name: 'roadiz_core.media_finder', platform: 'soundcloud' } ] + RZ\Roadiz\CoreBundle\Document\MediaFinder\MixcloudEmbedFinder: + tags: [ { name: 'roadiz_core.media_finder', platform: 'mixcloud' } ] + RZ\Roadiz\CoreBundle\Document\MediaFinder\SpotifyEmbedFinder: + tags: [ { name: 'roadiz_core.media_finder', platform: 'spotify' } ] + RZ\Roadiz\CoreBundle\Document\MediaFinder\TedEmbedFinder: + tags: [ { name: 'roadiz_core.media_finder', platform: 'ted' } ] + RZ\Roadiz\CoreBundle\Document\MediaFinder\PodcastFinder: + tags: [ { name: 'roadiz_core.media_finder', platform: 'podcast' } ] + RZ\Roadiz\CoreBundle\Document\MediaFinder\TwitchEmbedFinder: + tags: [ { name: 'roadiz_core.media_finder', platform: 'twitch' } ] + + # Removed DataTransformers + RZ\Roadiz\CoreBundle\Api\DataTransformer\: + resource: '../src/Api/DataTransformer/' + autowire: false + autoconfigure: false + + RZ\Roadiz\CoreBundle\Api\Dto\: + resource: '../src/Api/Dto/' + autowire: false + autoconfigure: false + + # Except for WebResponse + RZ\Roadiz\CoreBundle\Api\DataTransformer\WebResponseOutputDataTransformer: + autowire: true + autoconfigure: true + + RZ\Roadiz\CoreBundle\Api\Filter\: + resource: '../src/Api/Filter/' + autowire: true + tags: [ { name: 'api_platform.filter' } ] + + RZ\Roadiz\CoreBundle\Api\Extension\ArchiveExtension: + # Extension must be called after all filtering BUT before default pagination extension + tags: [ { name: 'api_platform.doctrine.orm.query_extension.collection', priority: -40 } ] + + RZ\Roadiz\CoreBundle\Bag\: + resource: '../src/Bag/' + autowire: true + public: true + + + RZ\Roadiz\CoreBundle\Api\OpenApi\JwtDecorator: + decorates: 'api_platform.openapi.factory' + arguments: [ '@RZ\Roadiz\CoreBundle\Api\OpenApi\JwtDecorator.inner' ] + autoconfigure: false + + # + # API Platform normalizers + # + RZ\Roadiz\CoreBundle\Serializer\Normalizer\RealmSerializationGroupNormalizer: + tags: + - { name: 'serializer.normalizer', priority: 64 } + + # Folder (must be decorate BEFORE DocumentNormalizer) + RZ\Roadiz\CoreBundle\Serializer\Normalizer\FolderNormalizer: + # By default, .inner is passed as argument + decorates: 'api_platform.jsonld.normalizer.item' + decoration_priority: 21 + # Need a different name to avoid duplicate YAML key + roadiz_core.serializer.normalizer.folder.json: + class: 'RZ\Roadiz\CoreBundle\Serializer\Normalizer\FolderNormalizer' + decorates: 'api_platform.serializer.normalizer.item' + decoration_priority: 21 + + # Document + RZ\Roadiz\CoreBundle\Serializer\Normalizer\DocumentNormalizer: + # By default, .inner is passed as argument + decorates: 'api_platform.jsonld.normalizer.item' + decoration_priority: 20 + # Need a different name to avoid duplicate YAML key + roadiz_core.serializer.normalizer.document.json: + class: 'RZ\Roadiz\CoreBundle\Serializer\Normalizer\DocumentNormalizer' + decorates: 'api_platform.serializer.normalizer.item' + decoration_priority: 20 + + # Serialize document sources (must be decorate AFTER DocumentNormalizer) + RZ\Roadiz\CoreBundle\Serializer\Normalizer\DocumentSourcesNormalizer: + decorates: 'api_platform.jsonld.normalizer.item' + decoration_priority: 19 + # Need a different name to avoid duplicate YAML key + roadiz_core.serializer.normalizer.document_sources.json: + class: 'RZ\Roadiz\CoreBundle\Serializer\Normalizer\DocumentSourcesNormalizer' + decorates: 'api_platform.serializer.normalizer.item' + decoration_priority: 19 + + # Tag + # requires DocumentNormalizer + RZ\Roadiz\CoreBundle\Serializer\Normalizer\TagNormalizer: + # By default, .inner is passed as argument + decorates: 'api_platform.jsonld.normalizer.item' + decoration_priority: 18 + # Need a different name to avoid duplicate YAML key + roadiz_core.serializer.normalizer.tag.json: + class: 'RZ\Roadiz\CoreBundle\Serializer\Normalizer\TagNormalizer' + decorates: 'api_platform.serializer.normalizer.item' + decoration_priority: 18 + + # CustomForm + # requires DocumentNormalizer + RZ\Roadiz\CoreBundle\Serializer\Normalizer\CustomFormNormalizer: + # By default, .inner is passed as argument + decorates: 'api_platform.jsonld.normalizer.item' + decoration_priority: 15 + # Need a different name to avoid duplicate YAML key + roadiz_core.serializer.normalizer.custom_form.json: + class: 'RZ\Roadiz\CoreBundle\Serializer\Normalizer\CustomFormNormalizer' + decorates: 'api_platform.serializer.normalizer.item' + decoration_priority: 15 + + # AttributeValue + # requires DocumentNormalizer + RZ\Roadiz\CoreBundle\Serializer\Normalizer\AttributeValueNormalizer: + # By default, .inner is passed as argument + decorates: 'api_platform.jsonld.normalizer.item' + decoration_priority: 15 + # Need a different name to avoid duplicate YAML key + roadiz_core.serializer.normalizer.attribute_value.json: + class: 'RZ\Roadiz\CoreBundle\Serializer\Normalizer\AttributeValueNormalizer' + decorates: 'api_platform.serializer.normalizer.item' + decoration_priority: 15 + + # NodesSources + RZ\Roadiz\CoreBundle\Serializer\Normalizer\NodesSourcesPathNormalizer: + # By default, .inner is passed as argument + decorates: 'api_platform.jsonld.normalizer.item' + decoration_priority: 5 + # Need a different name to avoid duplicate YAML key + roadiz_core.serializer.normalizer.nodes_sources_path.json: + class: 'RZ\Roadiz\CoreBundle\Serializer\Normalizer\NodesSourcesPathNormalizer' + decorates: 'api_platform.serializer.normalizer.item' + decoration_priority: 5 + + Limenius\Liform\LiformInterface: + alias: liform + + roadiz_core.liform.datetime_type.transformer: + class: Limenius\Liform\Transformer\StringTransformer + parent: Limenius\Liform\Transformer\AbstractTransformer + tags: + - { name: liform.transformer, form_type: datetime, widget: datetime } + + roadiz_core.liform.date_type.transformer: + class: Limenius\Liform\Transformer\StringTransformer + parent: Limenius\Liform\Transformer\AbstractTransformer + tags: + - { name: liform.transformer, form_type: date, widget: date } + + RZ\Roadiz\CoreBundle\Serializer\TranslationAwareContextBuilder: + decorates: 'api_platform.serializer.context_builder' + arguments: [ '@RZ\Roadiz\CoreBundle\Serializer\TranslationAwareContextBuilder.inner' ] + autowire: true + autoconfigure: false + + # Do not register roadiz/document packages event-subscribers + # They've been replaced with MessageHandlers + + RZ\Roadiz\CoreBundle\Document\MessageHandler\AbstractDocumentMessageHandler: + autoconfigure: false + abstract: true + + RZ\Roadiz\CoreBundle\Document\MessageHandler\AbstractLockingDocumentMessageHandler: + autoconfigure: false + abstract: true + + RZ\Roadiz\CoreBundle\Document\MessageHandler\DocumentAverageColorMessageHandler: + autoconfigure: false + tags: + - name: messenger.message_handler + handles: RZ\Roadiz\CoreBundle\Document\Message\DocumentAverageColorMessage + - { name: monolog.logger, channel: messenger } + + RZ\Roadiz\CoreBundle\Document\MessageHandler\DocumentExifMessageHandler: + autoconfigure: false + tags: + - name: messenger.message_handler + handles: RZ\Roadiz\CoreBundle\Document\Message\DocumentExifMessage + - { name: monolog.logger, channel: messenger } + + RZ\Roadiz\CoreBundle\Document\MessageHandler\DocumentFilesizeMessageHandler: + autoconfigure: false + tags: + - name: messenger.message_handler + handles: RZ\Roadiz\CoreBundle\Document\Message\DocumentFilesizeMessage + - { name: monolog.logger, channel: messenger } + + RZ\Roadiz\CoreBundle\Document\MessageHandler\DocumentRawMessageHandler: + autoconfigure: false + tags: + - name: messenger.message_handler + handles: RZ\Roadiz\CoreBundle\Document\Message\DocumentRawMessage + priority: -100 + - { name: monolog.logger, channel: messenger } + + RZ\Roadiz\CoreBundle\Document\MessageHandler\DocumentSizeMessageHandler: + autoconfigure: false + tags: + - name: messenger.message_handler + handles: RZ\Roadiz\CoreBundle\Document\Message\DocumentSizeMessage + - { name: monolog.logger, channel: messenger } + + RZ\Roadiz\CoreBundle\Document\MessageHandler\DocumentSvgMessageHandler: + autoconfigure: false + tags: + - name: messenger.message_handler + handles: RZ\Roadiz\CoreBundle\Document\Message\DocumentSvgMessage + priority: -100 + - { name: monolog.logger, channel: messenger } + + RZ\Roadiz\CoreBundle\Document\MessageHandler\DocumentAudioVideoMessageHandler: + autoconfigure: false + tags: + - name: messenger.message_handler + handles: RZ\Roadiz\CoreBundle\Document\Message\DocumentAudioVideoMessage + - { name: monolog.logger, channel: messenger } + + RZ\Roadiz\CoreBundle\SearchEngine\Indexer\: + resource: '../src/SearchEngine/Indexer/' + # Recreate handlers for each usage + shared: false + public: true + + RZ\Roadiz\CoreBundle\Mailer\EmailManager: + # Recreate manager for each usage + shared: false + + RZ\Roadiz\CoreBundle\Doctrine\EventSubscriber\: + resource: '../src/Doctrine/EventSubscriber' + tags: + - { name: monolog.logger, channel: doctrine } + - { name: doctrine.event_subscriber } + + RZ\Roadiz\Documents\Events\DocumentLifeCycleSubscriber: + tags: + - { name: monolog.logger, channel: doctrine } + - { name: doctrine.event_subscriber } + + RZ\Roadiz\Core\Events\LeafEntityLifeCycleSubscriber: + tags: + - { name: monolog.logger, channel: doctrine } + - { name: doctrine.event_subscriber } + + RZ\Roadiz\CoreBundle\Doctrine\SchemaUpdater: + tags: + - { name: monolog.logger, channel: doctrine } + + RZ\Roadiz\CoreBundle\Controller\: + resource: '../src/Controller' + tags: ['controller.service_arguments'] + + RZ\Roadiz\CoreBundle\Api\Controller\: + resource: '../src/Api/Controller' + tags: ['controller.service_arguments'] + + RZ\Roadiz\CoreBundle\Security\Authorization\Voter\: + resource: '../src/Security/Authorization/Voter' + tags: [ 'security.voter' ] + + RZ\Roadiz\CoreBundle\Preview\PreviewResolverInterface: + alias: RZ\Roadiz\CoreBundle\Preview\RequestPreviewRevolver + + RZ\Roadiz\CoreBundle\Preview\RequestPreviewRevolver: + arguments: + - '@Symfony\Component\HttpFoundation\RequestStack' + - 'ROLE_BACKEND_USER' + + RZ\Roadiz\CoreBundle\SearchEngine\ClientRegistry: + arguments: ['@service_container'] + + RZ\Roadiz\CoreBundle\SearchEngine\Indexer\IndexerFactory: + arguments: ['@service_container'] + + RZ\Roadiz\CoreBundle\EntityHandler\HandlerFactory: + arguments: ['@service_container'] + + RZ\Roadiz\Core\Handlers\HandlerFactoryInterface: + alias: RZ\Roadiz\CoreBundle\EntityHandler\HandlerFactory + + RZ\Roadiz\Contracts\NodeType\NodeTypeResolverInterface: + alias: RZ\Roadiz\CoreBundle\Bag\NodeTypes + public: true + + RZ\Roadiz\EntityGenerator\EntityGeneratorFactory: + arguments: + - '@RZ\Roadiz\CoreBundle\Bag\NodeTypes' + - '%roadiz_core.entity_generator_factory.options%' + + Gedmo\Loggable\LoggableListener: + alias: RZ\Roadiz\CoreBundle\Doctrine\Loggable\UserLoggableListener + + RZ\Roadiz\CoreBundle\Doctrine\Loggable\UserLoggableListener: + tags: + - { name: doctrine.event_subscriber, connection: default } + calls: + - [ setAnnotationReader, [ "@annotation_reader" ] ] + + Symfony\Component\Asset\VersionStrategy\VersionStrategyInterface: + alias: 'assets.empty_version_strategy' + + Doctrine\Persistence\ObjectManager: + alias: 'doctrine.orm.default_entity_manager' + + RZ\Roadiz\CoreBundle\Filesystem\RoadizFileDirectories: + arguments: ['%kernel.project_dir%'] + public: true + + RZ\Roadiz\Random\PasswordGenerator: ~ + + RZ\Crypto\KeyChain\KeyChainInterface: + alias: RZ\Crypto\KeyChain\AsymmetricFilesystemKeyChain + + RZ\Crypto\KeyChain\AsymmetricFilesystemKeyChain: + arguments: ['%kernel.project_dir%/var/secret', true] + + JMS\Serializer\Construction\ObjectConstructorInterface: + alias: RZ\Roadiz\CoreBundle\Serializer\ObjectConstructor\ObjectConstructor + + RZ\Roadiz\CoreBundle\Serializer\ObjectConstructor\ChainDoctrineObjectConstructor: + decorates: jms_serializer.doctrine_object_constructor + arguments: + - '@doctrine.orm.default_entity_manager' + - '@RZ\Roadiz\CoreBundle\Serializer\ObjectConstructor\ObjectConstructor' + - [ + '@RZ\Roadiz\CoreBundle\Serializer\ObjectConstructor\TranslationObjectConstructor', + '@RZ\Roadiz\CoreBundle\Serializer\ObjectConstructor\TagObjectConstructor', + '@RZ\Roadiz\CoreBundle\Serializer\ObjectConstructor\NodeObjectConstructor', + '@RZ\Roadiz\CoreBundle\Serializer\ObjectConstructor\NodeTypeObjectConstructor', + '@RZ\Roadiz\CoreBundle\Serializer\ObjectConstructor\NodeTypeFieldObjectConstructor', + '@RZ\Roadiz\CoreBundle\Serializer\ObjectConstructor\RoleObjectConstructor', + '@RZ\Roadiz\CoreBundle\Serializer\ObjectConstructor\GroupObjectConstructor', + '@RZ\Roadiz\CoreBundle\Serializer\ObjectConstructor\SettingObjectConstructor', + '@RZ\Roadiz\CoreBundle\Serializer\ObjectConstructor\SettingGroupObjectConstructor' + ] + + Solarium\Core\Client\Client: + factory: ['RZ\Roadiz\CoreBundle\SearchEngine\ClientRegistry', 'getClient'] + + # Provides LoginAttemptManager aware authentication + RZ\Roadiz\CoreBundle\Security\Authentication\JwtAuthenticationSuccessHandler: + decorates: 'lexik_jwt_authentication.handler.authentication_success' + + RZ\Roadiz\CoreBundle\Security\Authorization\Chroot\NodeChrootResolver: + alias: RZ\Roadiz\CoreBundle\Security\Authorization\Chroot\NodeChrootChainResolver + + RZ\Roadiz\CoreBundle\Security\Authorization\Chroot\NodeChrootChainResolver: + arguments: + - ['@RZ\Roadiz\CoreBundle\Security\Authorization\Chroot\RoadizUserNodeChrootResolver'] + + RZ\Roadiz\CoreBundle\Cache\Clearer\AssetsFileClearer: + arguments: + - '%kernel.project_dir%/public/assets' + + RZ\Roadiz\CoreBundle\Importer\AttributeImporter: + tags: ['roadiz_core.importer'] + RZ\Roadiz\CoreBundle\Importer\GroupsImporter: + tags: ['roadiz_core.importer'] + RZ\Roadiz\CoreBundle\Importer\NodeTypesImporter: + tags: ['roadiz_core.importer'] + RZ\Roadiz\CoreBundle\Importer\RolesImporter: + tags: ['roadiz_core.importer'] + RZ\Roadiz\CoreBundle\Importer\SettingsImporter: + tags: ['roadiz_core.importer'] + RZ\Roadiz\CoreBundle\Importer\TagsImporter: + tags: ['roadiz_core.importer'] + + # + # Markdown + # + League\CommonMark\Extension\Autolink\AutolinkExtension: + tags: ['roadiz_core.markdown.text_extra_converter.extension'] + League\CommonMark\Extension\ExternalLink\ExternalLinkExtension: + tags: ['roadiz_core.markdown.line_converter.extension', 'roadiz_core.markdown.text_converter.extension', 'roadiz_core.markdown.text_extra_converter.extension'] + League\CommonMark\Extension\Footnote\FootnoteExtension: + tags: ['roadiz_core.markdown.text_extra_converter.extension'] + League\CommonMark\Extension\InlinesOnly\InlinesOnlyExtension: + tags: ['roadiz_core.markdown.line_converter.extension'] + League\CommonMark\Extension\SmartPunct\SmartPunctExtension: + tags: ['roadiz_core.markdown.text_extra_converter.extension'] + League\CommonMark\Extension\Strikethrough\StrikethroughExtension: + tags: ['roadiz_core.markdown.text_converter.extension', 'roadiz_core.markdown.text_extra_converter.extension'] + League\CommonMark\Extension\TaskList\TaskListExtension: + tags: ['roadiz_core.markdown.text_extra_converter.extension'] + League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension: + tags: ['roadiz_core.markdown.text_converter.extension', 'roadiz_core.markdown.text_extra_converter.extension'] + League\CommonMark\Extension\Table\TableExtension: + tags: ['roadiz_core.markdown.text_converter.extension', 'roadiz_core.markdown.text_extra_converter.extension'] + + # + # Media finders + # + RZ\Roadiz\CoreBundle\Document\MediaFinder\UnsplashPictureFinder: + arguments: + - '%roadiz_core.medias.unsplash_client_id%' + RZ\Roadiz\Documents\MediaFinders\RandomImageFinder: + alias: RZ\Roadiz\CoreBundle\Document\MediaFinder\UnsplashPictureFinder + RZ\Roadiz\Documents\Renderer\ImageRenderer: + tags: [ 'roadiz_core.document_renderer' ] + RZ\Roadiz\Documents\Renderer\PictureRenderer: + tags: [ 'roadiz_core.document_renderer' ] + RZ\Roadiz\Documents\Renderer\VideoRenderer: + tags: [ 'roadiz_core.document_renderer' ] + RZ\Roadiz\Documents\Renderer\AudioRenderer: + tags: [ 'roadiz_core.document_renderer' ] + RZ\Roadiz\Documents\Renderer\PdfRenderer: + tags: [ 'roadiz_core.document_renderer' ] + RZ\Roadiz\Documents\Renderer\SvgRenderer: + tags: [ 'roadiz_core.document_renderer' ] + RZ\Roadiz\Documents\Renderer\InlineSvgRenderer: + tags: [ 'roadiz_core.document_renderer' ] + RZ\Roadiz\Documents\Renderer\EmbedRenderer: + tags: [ { name: 'roadiz_core.document_renderer', priority: -128 } ] + RZ\Roadiz\Documents\Renderer\ThumbnailRenderer: + tags: [ 'roadiz_core.document_renderer' ] + RZ\Roadiz\Documents\Renderer\RendererInterface: + alias: RZ\Roadiz\Documents\Renderer\ChainRenderer + + # Default AbstractDocumentFactory is the public one. + RZ\Roadiz\Documents\AbstractDocumentFactory: + alias: RZ\Roadiz\CoreBundle\Document\DocumentFactory + + RZ\Roadiz\Documents\Models\FileAwareInterface: + alias: RZ\Roadiz\CoreBundle\Filesystem\RoadizFileDirectories + public: true + + RZ\Roadiz\Documents\Packages: ~ + RZ\Roadiz\Documents\DownscaleImageManager: ~ + RZ\Roadiz\Documents\DocumentArchiver: ~ + # + # Console commands + # + RZ\Roadiz\Documents\Console\DocumentAverageColorCommand: + autowire: true + autoconfigure: true + RZ\Roadiz\Documents\Console\DocumentClearFolderCommand: + autowire: true + autoconfigure: true + RZ\Roadiz\Documents\Console\DocumentDownscaleCommand: + autowire: true + autoconfigure: true + RZ\Roadiz\Documents\Console\DocumentDuplicatesCommand: + autowire: true + autoconfigure: true + RZ\Roadiz\Documents\Console\DocumentFileHashCommand: + autowire: true + autoconfigure: true + RZ\Roadiz\Documents\Console\DocumentFilesizeCommand: + autowire: true + autoconfigure: true + RZ\Roadiz\Documents\Console\DocumentPruneCommand: + autowire: true + autoconfigure: true + RZ\Roadiz\Documents\Console\DocumentPruneOrphansCommand: + autowire: true + autoconfigure: true + RZ\Roadiz\Documents\Console\DocumentSizeCommand: + autowire: true + autoconfigure: true + + # + # Routing + # + RZ\Roadiz\CoreBundle\Routing\RedirectionRouter: + lazy: true + tags: [ { name: 'router', priority: 100 } ] + RZ\Roadiz\CoreBundle\Routing\NodeRouter: + lazy: true + tags: [ { name: 'router', priority: 0 } ] + + RZ\Roadiz\Documents\UrlGenerators\DocumentUrlGeneratorInterface: + alias: RZ\Roadiz\CoreBundle\Routing\DocumentUrlGenerator + + RZ\Roadiz\CoreBundle\Routing\PathResolverInterface: + class: RZ\Roadiz\CoreBundle\Routing\ChainResourcePathResolver + + RZ\Roadiz\CoreBundle\Routing\ChainResourcePathResolver: + alias: RZ\Roadiz\CoreBundle\Routing\PathResolverInterface + + # + # Add your own PathResolvers to serve resources + # + roadiz_core.path_resolvers.nodes_sources: + class: RZ\Roadiz\CoreBundle\Routing\NodesSourcesPathResolver + tags: [ { name: 'roadiz_core.path_resolver', priority: 0 } ] + roadiz_core.path_resolvers.redirections: + class: RZ\Roadiz\CoreBundle\Routing\RedirectionPathResolver + tags: [ { name: 'roadiz_core.path_resolver', priority: 10 } ] + # + # Twig + # + roadiz_core.twig_loader: + class: Twig\Loader\FilesystemLoader + calls: + - prependPath: ['%roadiz_core.documents_lib_dir%/Resources/views'] + tags: ['twig.loader'] + # + # Twig extensions + # + RZ\Roadiz\Documents\TwigExtension\DocumentExtension: + tags: ['twig.extension'] + RZ\Roadiz\Markdown\Twig\MarkdownExtension: + tags: ['twig.extension'] + RZ\Roadiz\CoreBundle\TwigExtension\BlockRenderExtension: + arguments: [ '@fragment.handler' ] + RZ\Roadiz\CoreBundle\TwigExtension\RoutingExtension: + decorates: 'twig.extension.routing' + autoconfigure: false + arguments: + - '@.inner' + + RZ\Roadiz\Documents\DocumentFinderInterface: + alias: RZ\Roadiz\CoreBundle\Document\DocumentFinder + + RZ\Roadiz\Documents\MediaFinders\EmbedFinderFactory: + arguments: + - '%roadiz_core.medias.supported_platforms%' + + RZ\Roadiz\Documents\Renderer\ChainRenderer: + arguments: [[]] + lazy: true + + RZ\Roadiz\CoreBundle\Node\NodeNamePolicyFactory: ~ + RZ\Roadiz\CoreBundle\Node\NodeNamePolicyInterface: + factory: ['@RZ\Roadiz\CoreBundle\Node\NodeNamePolicyFactory', 'create'] + + # + # Workflows + # + state_machine.node: + public: true + alias: RZ\Roadiz\CoreBundle\Workflow\NodeWorkflow + + RZ\TreeWalker\WalkerContextInterface: + factory: [ '@RZ\Roadiz\CoreBundle\Api\TreeWalker\NodeSourceWalkerContextFactory', 'createWalkerContext' ] diff --git a/lib/RoadizCoreBundle/crowdin.yml b/lib/RoadizCoreBundle/crowdin.yml new file mode 100644 index 00000000..50e504da --- /dev/null +++ b/lib/RoadizCoreBundle/crowdin.yml @@ -0,0 +1,8 @@ +files: + - source: /**/*.xlf + ignore: + - /**/*.%two_letters_code%.xlf + - /**/*.%locale_with_underscore%.xlf + - /**/*.%locale%.xlf + - /**/*.sr_Cyrl.xlf + translation: /%original_path%/%file_name%.%two_letters_code%.%file_extension% diff --git a/lib/RoadizCoreBundle/css/README.md b/lib/RoadizCoreBundle/css/README.md new file mode 100644 index 00000000..fa50ca9c --- /dev/null +++ b/lib/RoadizCoreBundle/css/README.md @@ -0,0 +1,3 @@ +## Inlined only CSS styles + +These CSS files are meant to be inlined into HTML templates. They won’t be reachable from webserver. diff --git a/lib/RoadizCoreBundle/css/transactionalStyles.css b/lib/RoadizCoreBundle/css/transactionalStyles.css new file mode 100644 index 00000000..29b2323a --- /dev/null +++ b/lib/RoadizCoreBundle/css/transactionalStyles.css @@ -0,0 +1,313 @@ +/* ------------------------------------- + GLOBAL + A very basic CSS reset + https://github.com/mailgun/transactional-email-templates/ +------------------------------------- */ +* { + margin: 0; + padding: 0; + font-family: Arial, Helvetica, sans-serif; + box-sizing: border-box; + font-size: 13px; +} + +img { + max-width: 100%; +} + +.header-image { + max-height: 150px; + max-width: 200px; + width: auto; + height: auto; +} + +.header-image-wrap { + padding: 25px 20px 20px; +} + +body { + -webkit-font-smoothing: antialiased; + -webkit-text-size-adjust: none; + width: 100% !important; + height: 100%; + line-height: 1.6; +} + +/* Let's make sure all tables have defaults */ +table td { + vertical-align: top; +} + +/* ------------------------------------- + BODY & CONTAINER +------------------------------------- */ +body { + background-color: #f6f6f6; +} + +.body-wrap { + background-color: #f6f6f6; + width: 100%; +} + +.container { + display: block !important; + max-width: 600px !important; + margin: 0 auto !important; + /* makes it centered */ + clear: both !important; +} + +.content { + max-width: 600px; + margin: 0 auto; + display: block; + padding: 20px; +} + +/* ------------------------------------- + HEADER, FOOTER, MAIN +------------------------------------- */ +.main { + background: #fff; + border: 1px solid #e9e9e9; + border-radius: 3px; +} + +.content-wrap { + padding: 20px; +} + +.content-wrap.with-header-image { + padding: 0 20px 20px; +} + +.content-block { + padding: 0 0 20px; +} + +.header { + width: 100%; + margin-bottom: 20px; +} + +.footer { + width: 100%; + clear: both; + color: #999; + padding: 20px; +} +.footer a { + color: #999; +} +.footer p, +.footer a, +.footer em, +.footer strong, +.footer unsubscribe, +.footer td { + font-size: 12px; +} + +.long-link { + font-size: 11px; +} + +/* ------------------------------------- + TYPOGRAPHY +------------------------------------- */ +h1, h2, h3 { + font-family: "Helvetica Neue", Helvetica, Arial, "Lucida Grande", sans-serif; + color: #000; + margin: 0; + line-height: 1.2; + font-weight: 400; +} + +code { + font-family: Courier, monospace; + color: #333; +} + +h1 { + margin: 30px 0 0; +} +h1, +h1 em { + font-size: 28px; + font-weight: 500; +} + +h2, +h2 em { + font-size: 22px; +} + +h3, +h3 em { + font-size: 18px; +} + +h4, +h4 em { + font-size: 14px; + font-weight: 600; +} + +p, ul, ol { + margin-bottom: 10px; + font-weight: normal; +} +p li, ul li, ol li { + margin-left: 5px; + list-style-position: inside; +} + +/* ------------------------------------- + LINKS & BUTTONS +------------------------------------- */ +a { + color: #348eda; + text-decoration: underline; +} + +.btn-primary { + text-decoration: none; + color: #FFF; + background-color: #348eda; + border: solid #348eda; + border-width: 10px 20px; + line-height: 2; + font-weight: bold; + text-align: center; + cursor: pointer; + display: inline-block; + border-radius: 5px; +} + +/* ------------------------------------- + OTHER STYLES THAT MIGHT BE USEFUL +------------------------------------- */ +.last { + margin-bottom: 0; +} + +.first { + margin-top: 0; +} + +.aligncenter { + text-align: center; +} + +.alignright { + text-align: right; +} + +.paddingright { + padding-right: 14px; +} + +.alignleft { + text-align: left; +} + +.clear { + clear: both; +} + +/* ------------------------------------- + ALERTS + Change the class depending on warning email, good email or bad email +------------------------------------- */ +.alert { + font-size: 16px; + color: #fff; + font-weight: 500; + padding: 20px; + text-align: center; + border-radius: 3px 3px 0 0; +} +.alert a { + color: #fff; + text-decoration: none; + font-weight: 500; + font-size: 16px; +} +.alert.alert-warning { + background: #ff9f00; +} +.alert.alert-bad, +.alert.alert-danger { + background: #d0021b; +} +.alert.alert-good, +.alert.alert-success { + background: #68b90f; +} + +/* ------------------------------------- + INVOICE + Styles for the billing table +------------------------------------- */ +.invoice { + margin: 20px auto; + text-align: left; + width: 100%; +} +.invoice td { + padding: 5px 0; +} +.invoice .invoice-items { + width: 100%; +} +.invoice .invoice-items td { + border-top: #eee 1px solid; +} +.invoice .invoice-items td:first-child { + width: 25%; +} +.invoice .invoice-items .total td { + padding: 10px 0 5px; + border-top: 0; + border-bottom: 2px solid #333; + font-weight: 700; +} + +/* ------------------------------------- + RESPONSIVE AND MOBILE FRIENDLY STYLES +------------------------------------- */ +@media only screen and (max-width: 640px) { + h1, h2, h3, h4 { + font-weight: 600 !important; + margin: 20px 0 5px !important; + } + + h1, + h1 em { + font-size: 22px !important; + } + + h2, + h2 em { + font-size: 18px !important; + } + + h3, + h3 em { + font-size: 16px !important; + } + + .container { + width: 100% !important; + } + + .content, .content-wrapper { + padding: 10px !important; + } + + .invoice { + width: 100% !important; + } +} diff --git a/lib/RoadizCoreBundle/manifest.json b/lib/RoadizCoreBundle/manifest.json new file mode 100644 index 00000000..444873ec --- /dev/null +++ b/lib/RoadizCoreBundle/manifest.json @@ -0,0 +1,38 @@ +{ + "bundles": { + "RZ\\Roadiz\\CoreBundle\\RoadizCoreBundle": ["all"] + }, + "copy-from-package": { + "config/api_resources/": "%CONFIG_DIR%/api_resources/", + "config/packages/": "%CONFIG_DIR%/packages/", + "var/": "%VAR_DIR%/", + "public/": "%PUBLIC_DIR%/", + "themes/": "themes/" + }, + "env": { + "APP_NAMESPACE": "my_website", + "APP_VERSION": "0.1.0", + "#2": "Make sure ffmpeg is installed on your environment", + "APP_FFMPEG_PATH": "/usr/bin/ffmpeg", + "APP_UNSPLASH_CLIENT_ID": null, + "APP_USE_ACCEPT_LANGUAGE_HEADER": false, + "APP_HEALTH_CHECK_TOKEN": null, + "#3": "Solr server credentials should be used by docker-compose env too", + "SOLR_HOST": "solr", + "SOLR_PORT": 8983, + "SOLR_CORE_NAME": "roadiz", + "#4": "Customize API platform cache invalidation and settings", + "VARNISH_URL": null, + "VARNISH_DOMAIN": null, + "HTTP_CACHE_MAX_AGE": 60, + "HTTP_CACHE_SHARED_MAX_AGE": 600 + }, + "aliases": ["roadiz-core"], + "gitignore": [ + "/public/assets/*", + "/public/files/*", + "/var/files/fonts/*", + "/var/files/private/*", + "/var/secret/*" + ] +} diff --git a/lib/RoadizCoreBundle/migrations/Version20201203004857.php b/lib/RoadizCoreBundle/migrations/Version20201203004857.php new file mode 100644 index 00000000..746639af --- /dev/null +++ b/lib/RoadizCoreBundle/migrations/Version20201203004857.php @@ -0,0 +1,151 @@ +skipIf( + $this->connection->getDatabasePlatform()->getName() !== 'mysql', + 'Migration can only be executed safely on \'mysql\'.' + ); + $this->skipIf($schema->hasTable('nodes'), 'Database has been initialized before Doctrine Migration tool.'); + + $this->addSql('CREATE TABLE attribute_group_translations (id INT AUTO_INCREMENT NOT NULL, attribute_group_id INT DEFAULT NULL, translation_id INT DEFAULT NULL, name VARCHAR(255) NOT NULL, INDEX IDX_5C704A6862D643B7 (attribute_group_id), INDEX IDX_5C704A689CAA2B25 (translation_id), INDEX IDX_5C704A685E237E06 (name), UNIQUE INDEX UNIQ_5C704A6862D643B79CAA2B25 (attribute_group_id, translation_id), UNIQUE INDEX UNIQ_5C704A685E237E069CAA2B25 (name, translation_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('CREATE TABLE attribute_groups (id INT AUTO_INCREMENT NOT NULL, canonical_name VARCHAR(255) NOT NULL, UNIQUE INDEX UNIQ_D28C172A674D812 (canonical_name), INDEX IDX_D28C172A674D812 (canonical_name), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('CREATE TABLE attribute_translations (id INT AUTO_INCREMENT NOT NULL, attribute_id INT DEFAULT NULL, translation_id INT DEFAULT NULL, label VARCHAR(255) NOT NULL, options LONGTEXT DEFAULT NULL COMMENT \'(DC2Type:simple_array)\', INDEX IDX_4059D4A0B6E62EFA (attribute_id), INDEX IDX_4059D4A09CAA2B25 (translation_id), INDEX IDX_4059D4A0EA750E8 (label), UNIQUE INDEX UNIQ_4059D4A0B6E62EFA9CAA2B25 (attribute_id, translation_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('CREATE TABLE attribute_value_translations (id INT AUTO_INCREMENT NOT NULL, translation_id INT DEFAULT NULL, attribute_value INT DEFAULT NULL, value VARCHAR(255) DEFAULT NULL, INDEX IDX_1293849B9CAA2B25 (translation_id), INDEX IDX_1293849BFE4FBB82 (attribute_value), INDEX IDX_1293849B1D775834 (value), INDEX IDX_1293849B9CAA2B25FE4FBB82 (translation_id, attribute_value), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('CREATE TABLE attribute_values (id INT AUTO_INCREMENT NOT NULL, attribute_id INT DEFAULT NULL, node_id INT DEFAULT NULL, position DOUBLE PRECISION NOT NULL, INDEX IDX_184662BCB6E62EFA (attribute_id), INDEX IDX_184662BC460D9FD7 (node_id), INDEX IDX_184662BCB6E62EFA460D9FD7 (attribute_id, node_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('CREATE TABLE attributes (id INT AUTO_INCREMENT NOT NULL, group_id INT DEFAULT NULL, code VARCHAR(255) NOT NULL, searchable TINYINT(1) DEFAULT \'0\' NOT NULL, type INT NOT NULL, color VARCHAR(7) DEFAULT NULL, UNIQUE INDEX UNIQ_319B9E7077153098 (code), INDEX IDX_319B9E7077153098 (code), INDEX IDX_319B9E708CDE5729 (type), INDEX IDX_319B9E7094CD8C0D (searchable), INDEX IDX_319B9E70FE54D947 (group_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('CREATE TABLE attributes_documents (id INT AUTO_INCREMENT NOT NULL, attribute_id INT DEFAULT NULL, document_id INT DEFAULT NULL, position DOUBLE PRECISION NOT NULL, INDEX IDX_67CCC9E0B6E62EFA (attribute_id), INDEX IDX_67CCC9E0C33F7837 (document_id), INDEX IDX_67CCC9E0462CE4F5 (position), INDEX IDX_67CCC9E0B6E62EFA462CE4F5 (attribute_id, position), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('CREATE TABLE custom_form_answers (id INT AUTO_INCREMENT NOT NULL, custom_form_id INT DEFAULT NULL, ip VARCHAR(255) NOT NULL, submitted_at DATETIME NOT NULL, INDEX IDX_1A3BB12658AFF2B0 (custom_form_id), INDEX IDX_1A3BB126A5E3B32D (ip), INDEX IDX_1A3BB1263182C73C (submitted_at), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('CREATE TABLE custom_form_field_attributes (id INT AUTO_INCREMENT NOT NULL, custom_form_answer_id INT DEFAULT NULL, custom_form_field_id INT DEFAULT NULL, value LONGTEXT DEFAULT NULL, INDEX IDX_B7133605F1D6C2D1 (custom_form_answer_id), INDEX IDX_B71336057F13CC0F (custom_form_field_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('CREATE TABLE custom_form_answers_documents (customformfieldattribute_id INT NOT NULL, document_id INT NOT NULL, INDEX IDX_E979F877C84CA2FC (customformfieldattribute_id), INDEX IDX_E979F877C33F7837 (document_id), PRIMARY KEY(customformfieldattribute_id, document_id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('CREATE TABLE custom_form_fields (id INT AUTO_INCREMENT NOT NULL, custom_form_id INT DEFAULT NULL, name VARCHAR(255) NOT NULL, label VARCHAR(255) NOT NULL, placeholder VARCHAR(255) DEFAULT NULL, description LONGTEXT DEFAULT NULL, default_values LONGTEXT DEFAULT NULL, type INT NOT NULL, expanded TINYINT(1) DEFAULT \'0\' NOT NULL, field_required TINYINT(1) DEFAULT \'0\' NOT NULL, group_name VARCHAR(255) DEFAULT NULL, group_name_canonical VARCHAR(255) DEFAULT NULL, position DOUBLE PRECISION NOT NULL, INDEX IDX_4A3782EC58AFF2B0 (custom_form_id), INDEX IDX_4A3782EC462CE4F5 (position), INDEX IDX_4A3782EC77792576 (group_name), INDEX IDX_4A3782EC8CDE5729 (type), UNIQUE INDEX UNIQ_4A3782EC5E237E0658AFF2B0 (name, custom_form_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('CREATE TABLE custom_forms (id INT AUTO_INCREMENT NOT NULL, color VARCHAR(255) DEFAULT NULL, name VARCHAR(255) NOT NULL, display_name VARCHAR(255) NOT NULL, description LONGTEXT DEFAULT NULL, email LONGTEXT DEFAULT NULL, open TINYINT(1) DEFAULT \'1\' NOT NULL, close_date DATETIME DEFAULT NULL, created_at DATETIME DEFAULT NULL, updated_at DATETIME DEFAULT NULL, UNIQUE INDEX UNIQ_3E32E39E5E237E06 (name), INDEX IDX_3E32E39E8B8E8428 (created_at), INDEX IDX_3E32E39E43625D9F (updated_at), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('CREATE TABLE documents (id INT AUTO_INCREMENT NOT NULL, raw_document INT DEFAULT NULL, original INT DEFAULT NULL, raw TINYINT(1) DEFAULT \'0\' NOT NULL, embedId VARCHAR(255) DEFAULT NULL, embedPlatform VARCHAR(255) DEFAULT NULL, filename VARCHAR(255) DEFAULT NULL, mime_type VARCHAR(255) DEFAULT NULL, folder VARCHAR(255) NOT NULL, private TINYINT(1) DEFAULT \'0\' NOT NULL, imageWidth INT DEFAULT 0 NOT NULL, imageHeight INT DEFAULT 0 NOT NULL, average_color VARCHAR(7) DEFAULT NULL, filesize INT DEFAULT NULL, created_at DATETIME DEFAULT NULL, updated_at DATETIME DEFAULT NULL, UNIQUE INDEX UNIQ_A2B0728826CBD5A5 (raw_document), INDEX IDX_A2B072882F727085 (original), INDEX IDX_A2B072881AB3DB55 (raw), INDEX IDX_A2B07288D206C1D1 (private), INDEX IDX_A2B072881AB3DB55D206C1D1 (raw, private), INDEX IDX_A2B072882100AA2E (mime_type), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('CREATE TABLE documents_translations (id INT AUTO_INCREMENT NOT NULL, translation_id INT DEFAULT NULL, document_id INT DEFAULT NULL, name VARCHAR(255) DEFAULT NULL, description LONGTEXT DEFAULT NULL, copyright LONGTEXT DEFAULT NULL, INDEX IDX_5CD2F5509CAA2B25 (translation_id), INDEX IDX_5CD2F550C33F7837 (document_id), UNIQUE INDEX UNIQ_5CD2F550C33F78379CAA2B25 (document_id, translation_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('CREATE TABLE folders (id INT AUTO_INCREMENT NOT NULL, parent_id INT DEFAULT NULL, folder_name VARCHAR(255) NOT NULL, visible TINYINT(1) DEFAULT \'1\' NOT NULL, position DOUBLE PRECISION NOT NULL, created_at DATETIME DEFAULT NULL, updated_at DATETIME DEFAULT NULL, UNIQUE INDEX UNIQ_FE37D30F47BC5813 (folder_name), INDEX IDX_FE37D30F727ACA70 (parent_id), INDEX IDX_FE37D30F7AB0E859 (visible), INDEX IDX_FE37D30F462CE4F5 (position), INDEX IDX_FE37D30F8B8E8428 (created_at), INDEX IDX_FE37D30F43625D9F (updated_at), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('CREATE TABLE documents_folders (folder_id INT NOT NULL, document_id INT NOT NULL, INDEX IDX_617BB29C162CB942 (folder_id), INDEX IDX_617BB29CC33F7837 (document_id), PRIMARY KEY(folder_id, document_id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('CREATE TABLE folders_translations (id INT AUTO_INCREMENT NOT NULL, folder_id INT DEFAULT NULL, translation_id INT DEFAULT NULL, name VARCHAR(255) NOT NULL, INDEX IDX_9F6A68B2162CB942 (folder_id), INDEX IDX_9F6A68B29CAA2B25 (translation_id), UNIQUE INDEX UNIQ_9F6A68B2162CB9429CAA2B25 (folder_id, translation_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('CREATE TABLE `groups` (id INT AUTO_INCREMENT NOT NULL, name VARCHAR(255) NOT NULL, UNIQUE INDEX UNIQ_F06D39705E237E06 (name), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('CREATE TABLE groups_roles (group_id INT NOT NULL, role_id INT NOT NULL, INDEX IDX_E79D4963FE54D947 (group_id), INDEX IDX_E79D4963D60322AC (role_id), PRIMARY KEY(group_id, role_id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('CREATE TABLE log (id INT AUTO_INCREMENT NOT NULL, user_id INT DEFAULT NULL, node_source_id INT DEFAULT NULL, username VARCHAR(255) DEFAULT NULL, message LONGTEXT NOT NULL, level INT NOT NULL, datetime DATETIME NOT NULL, client_ip VARCHAR(255) DEFAULT NULL, channel VARCHAR(255) DEFAULT NULL, additional_data JSON DEFAULT NULL, INDEX IDX_8F3F68C5A76ED395 (user_id), INDEX IDX_8F3F68C58E831402 (node_source_id), INDEX IDX_8F3F68C593F3C6CA (datetime), INDEX IDX_8F3F68C59AEACC13 (level), INDEX IDX_8F3F68C5F85E0677 (username), INDEX IDX_8F3F68C5A2F98E47 (channel), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('CREATE TABLE login_attempts (id INT AUTO_INCREMENT NOT NULL, ip_address VARCHAR(50) DEFAULT NULL, date DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', blocks_login_until DATETIME DEFAULT NULL, username VARCHAR(255) NOT NULL, attempt_count INT DEFAULT NULL, INDEX IDX_9163C7FBF85E0677 (username), INDEX IDX_9163C7FBEFF8A4EEF85E0677 (blocks_login_until, username), INDEX IDX_9163C7FBEFF8A4EEF85E067722FFD58C (blocks_login_until, username, ip_address), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('CREATE TABLE node_type_fields (id INT AUTO_INCREMENT NOT NULL, node_type_id INT DEFAULT NULL, name VARCHAR(255) NOT NULL, label VARCHAR(255) NOT NULL, placeholder VARCHAR(255) DEFAULT NULL, description LONGTEXT DEFAULT NULL, default_values LONGTEXT DEFAULT NULL, type INT NOT NULL, expanded TINYINT(1) DEFAULT \'0\' NOT NULL, universal TINYINT(1) DEFAULT \'0\' NOT NULL, exclude_from_search TINYINT(1) DEFAULT \'0\' NOT NULL, min_length INT DEFAULT NULL, max_length INT DEFAULT NULL, indexed TINYINT(1) DEFAULT \'0\' NOT NULL, visible TINYINT(1) DEFAULT \'1\' NOT NULL, group_name VARCHAR(255) DEFAULT NULL, group_name_canonical VARCHAR(255) DEFAULT NULL, position DOUBLE PRECISION NOT NULL, INDEX IDX_1D3923596344C9E1 (node_type_id), INDEX IDX_1D3923597AB0E859 (visible), INDEX IDX_1D392359D9416D95 (indexed), INDEX IDX_1D392359462CE4F5 (position), INDEX IDX_1D39235977792576 (group_name), INDEX IDX_1D3923594BAF07A4 (group_name_canonical), INDEX IDX_1D3923598CDE5729 (type), INDEX IDX_1D392359A4B8F6E1 (universal), UNIQUE INDEX UNIQ_1D3923595E237E066344C9E1 (name, node_type_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('CREATE TABLE node_types (id INT AUTO_INCREMENT NOT NULL, name VARCHAR(255) NOT NULL, display_name VARCHAR(255) NOT NULL, description LONGTEXT DEFAULT NULL, visible TINYINT(1) DEFAULT \'1\' NOT NULL, publishable TINYINT(1) DEFAULT \'0\' NOT NULL, reachable TINYINT(1) DEFAULT \'1\' NOT NULL, hiding_nodes TINYINT(1) DEFAULT \'0\' NOT NULL, hiding_non_reachable_nodes TINYINT(1) DEFAULT \'0\' NOT NULL, color VARCHAR(255) DEFAULT NULL, default_ttl INT DEFAULT 0 NOT NULL, UNIQUE INDEX UNIQ_409B1BCC5E237E06 (name), INDEX IDX_409B1BCC7AB0E859 (visible), INDEX IDX_409B1BCC7697C594 (publishable), INDEX IDX_409B1BCCFB696FF0 (hiding_nodes), INDEX IDX_409B1BCC5A3C14C7 (hiding_non_reachable_nodes), INDEX IDX_409B1BCC96ED695F (reachable), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('CREATE TABLE nodes (id INT AUTO_INCREMENT NOT NULL, parent_node_id INT DEFAULT NULL, node_name VARCHAR(255) NOT NULL, dynamic_node_name TINYINT(1) DEFAULT \'1\' NOT NULL, home TINYINT(1) DEFAULT \'0\' NOT NULL, visible TINYINT(1) DEFAULT \'1\' NOT NULL, status INT NOT NULL, ttl INT DEFAULT 0 NOT NULL, locked TINYINT(1) DEFAULT \'0\' NOT NULL, priority NUMERIC(2, 1) NOT NULL, hide_children TINYINT(1) DEFAULT \'0\' NOT NULL, sterile TINYINT(1) DEFAULT \'0\' NOT NULL, children_order VARCHAR(255) NOT NULL, children_order_direction VARCHAR(4) NOT NULL, position DOUBLE PRECISION NOT NULL, created_at DATETIME DEFAULT NULL, updated_at DATETIME DEFAULT NULL, nodeType_id INT DEFAULT NULL, UNIQUE INDEX UNIQ_1D3D05FC9987F390 (node_name), INDEX IDX_1D3D05FC47D04729 (nodeType_id), INDEX IDX_1D3D05FC3445EB91 (parent_node_id), INDEX IDX_1D3D05FC7AB0E859 (visible), INDEX IDX_1D3D05FC7B00651C (status), INDEX IDX_1D3D05FCEAD2C891 (locked), INDEX IDX_1D3D05FCF32D8BE6 (sterile), INDEX IDX_1D3D05FC462CE4F5 (position), INDEX IDX_1D3D05FC8B8E8428 (created_at), INDEX IDX_1D3D05FC43625D9F (updated_at), INDEX IDX_1D3D05FC50E2D3D2 (hide_children), INDEX IDX_1D3D05FC9987F3907B00651C (node_name, status), INDEX IDX_1D3D05FC7AB0E8597B00651C (visible, status), INDEX IDX_1D3D05FC7AB0E8597B00651C3445EB91 (visible, status, parent_node_id), INDEX IDX_1D3D05FC7AB0E8593445EB91 (visible, parent_node_id), INDEX IDX_1D3D05FC71D60CD0 (home), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('CREATE TABLE nodes_tags (node_id INT NOT NULL, tag_id INT NOT NULL, INDEX IDX_5B5CB38C460D9FD7 (node_id), INDEX IDX_5B5CB38CBAD26311 (tag_id), PRIMARY KEY(node_id, tag_id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('CREATE TABLE stack_types (node_id INT NOT NULL, nodetype_id INT NOT NULL, INDEX IDX_DE24E53460D9FD7 (node_id), INDEX IDX_DE24E53886D7EB5 (nodetype_id), PRIMARY KEY(node_id, nodetype_id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('CREATE TABLE nodes_custom_forms (id INT AUTO_INCREMENT NOT NULL, node_id INT DEFAULT NULL, custom_form_id INT DEFAULT NULL, node_type_field_id INT DEFAULT NULL, position DOUBLE PRECISION NOT NULL, INDEX IDX_4D401A0C460D9FD7 (node_id), INDEX IDX_4D401A0C58AFF2B0 (custom_form_id), INDEX IDX_4D401A0C47705282 (node_type_field_id), INDEX IDX_4D401A0C462CE4F5 (position), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('CREATE TABLE nodes_sources (id INT AUTO_INCREMENT NOT NULL, node_id INT DEFAULT NULL, translation_id INT DEFAULT NULL, title VARCHAR(255) DEFAULT NULL, published_at DATETIME DEFAULT NULL, meta_title VARCHAR(255) NOT NULL, meta_keywords LONGTEXT NOT NULL, meta_description LONGTEXT NOT NULL, discr VARCHAR(255) NOT NULL, INDEX IDX_7C7DED6D460D9FD7 (node_id), INDEX IDX_7C7DED6D9CAA2B25 (translation_id), INDEX IDX_7C7DED6D4AD26064 (discr), INDEX IDX_7C7DED6D4AD260649CAA2B25 (discr, translation_id), INDEX IDX_7C7DED6DE0D4FDE14AD260649CAA2B25 (published_at, discr, translation_id), INDEX IDX_7C7DED6D2B36786B (title), INDEX IDX_7C7DED6DE0D4FDE1 (published_at), INDEX IDX_7C7DED6DE0D4FDE19CAA2B25 (published_at, translation_id), INDEX IDX_7C7DED6D460D9FD79CAA2B25E0D4FDE1 (node_id, translation_id, published_at), INDEX IDX_7C7DED6D2B36786BE0D4FDE1 (title, published_at), INDEX IDX_7C7DED6D2B36786BE0D4FDE19CAA2B25 (title, published_at, translation_id), UNIQUE INDEX UNIQ_7C7DED6D460D9FD79CAA2B25 (node_id, translation_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('CREATE TABLE nodes_sources_documents (id INT AUTO_INCREMENT NOT NULL, ns_id INT DEFAULT NULL, document_id INT DEFAULT NULL, node_type_field_id INT DEFAULT NULL, position DOUBLE PRECISION NOT NULL, INDEX IDX_1CD104F7AA2D61 (ns_id), INDEX IDX_1CD104F7C33F7837 (document_id), INDEX IDX_1CD104F747705282 (node_type_field_id), INDEX IDX_1CD104F7462CE4F5 (position), INDEX IDX_1CD104F7AA2D6147705282 (ns_id, node_type_field_id), INDEX IDX_1CD104F7AA2D6147705282462CE4F5 (ns_id, node_type_field_id, position), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('CREATE TABLE nodes_to_nodes (id INT AUTO_INCREMENT NOT NULL, node_a_id INT DEFAULT NULL, node_b_id INT DEFAULT NULL, node_type_field_id INT DEFAULT NULL, position DOUBLE PRECISION NOT NULL, INDEX IDX_761F9A91FC7ADECE (node_a_id), INDEX IDX_761F9A91EECF7120 (node_b_id), INDEX IDX_761F9A9147705282 (node_type_field_id), INDEX IDX_761F9A91462CE4F5 (position), INDEX IDX_761F9A91FC7ADECE47705282 (node_a_id, node_type_field_id), INDEX IDX_761F9A91FC7ADECE47705282462CE4F5 (node_a_id, node_type_field_id, position), INDEX IDX_761F9A91EECF712047705282 (node_b_id, node_type_field_id), INDEX IDX_761F9A91EECF712047705282462CE4F5 (node_b_id, node_type_field_id, position), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('CREATE TABLE redirections (id INT AUTO_INCREMENT NOT NULL, ns_id INT DEFAULT NULL, query VARCHAR(255) NOT NULL, redirectUri VARCHAR(255) DEFAULT NULL, type INT NOT NULL, created_at DATETIME DEFAULT NULL, updated_at DATETIME DEFAULT NULL, UNIQUE INDEX UNIQ_38F5ECE424BDB5EB (query), INDEX IDX_38F5ECE4AA2D61 (ns_id), INDEX IDX_38F5ECE48B8E8428 (created_at), INDEX IDX_38F5ECE443625D9F (updated_at), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('CREATE TABLE roles (id INT AUTO_INCREMENT NOT NULL, name VARCHAR(255) NOT NULL, UNIQUE INDEX UNIQ_B63E2EC75E237E06 (name), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('CREATE TABLE settings (id INT AUTO_INCREMENT NOT NULL, setting_group_id INT DEFAULT NULL, name VARCHAR(255) NOT NULL, description LONGTEXT DEFAULT NULL, value LONGTEXT DEFAULT NULL, visible TINYINT(1) DEFAULT \'1\' NOT NULL, encrypted TINYINT(1) DEFAULT \'0\' NOT NULL, type INT NOT NULL, defaultValues LONGTEXT DEFAULT NULL, UNIQUE INDEX UNIQ_E545A0C55E237E06 (name), INDEX IDX_E545A0C550DDE1BD (setting_group_id), INDEX IDX_E545A0C58CDE5729 (type), INDEX IDX_E545A0C55E237E06 (name), INDEX IDX_E545A0C57AB0E859 (visible), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('CREATE TABLE settings_groups (id INT AUTO_INCREMENT NOT NULL, name VARCHAR(255) NOT NULL, in_menu TINYINT(1) DEFAULT \'0\' NOT NULL, UNIQUE INDEX UNIQ_FFD519025E237E06 (name), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('CREATE TABLE tags (id INT AUTO_INCREMENT NOT NULL, parent_tag_id INT DEFAULT NULL, color VARCHAR(7) DEFAULT \'#000000\' NOT NULL, tag_name VARCHAR(255) NOT NULL, visible TINYINT(1) DEFAULT \'1\' NOT NULL, children_order VARCHAR(255) DEFAULT \'position\' NOT NULL, children_order_direction VARCHAR(4) DEFAULT \'ASC\' NOT NULL, locked TINYINT(1) DEFAULT \'0\' NOT NULL, position DOUBLE PRECISION NOT NULL, created_at DATETIME DEFAULT NULL, updated_at DATETIME DEFAULT NULL, UNIQUE INDEX UNIQ_6FBC9426B02CC1B0 (tag_name), INDEX IDX_6FBC9426F5C1A0D7 (parent_tag_id), INDEX IDX_6FBC94267AB0E859 (visible), INDEX IDX_6FBC9426EAD2C891 (locked), INDEX IDX_6FBC9426462CE4F5 (position), INDEX IDX_6FBC94268B8E8428 (created_at), INDEX IDX_6FBC942643625D9F (updated_at), INDEX IDX_6FBC9426F5C1A0D77AB0E859 (parent_tag_id, visible), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('CREATE TABLE tags_translations (id INT AUTO_INCREMENT NOT NULL, tag_id INT DEFAULT NULL, translation_id INT DEFAULT NULL, name VARCHAR(255) NOT NULL, description LONGTEXT DEFAULT NULL, INDEX IDX_95D326DCBAD26311 (tag_id), INDEX IDX_95D326DC9CAA2B25 (translation_id), UNIQUE INDEX UNIQ_95D326DCBAD263119CAA2B25 (tag_id, translation_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('CREATE TABLE tags_translations_documents (id INT AUTO_INCREMENT NOT NULL, tag_translation_id INT DEFAULT NULL, document_id INT DEFAULT NULL, position DOUBLE PRECISION NOT NULL, INDEX IDX_6E886F1F22010F1 (tag_translation_id), INDEX IDX_6E886F1FC33F7837 (document_id), INDEX IDX_6E886F1F462CE4F5 (position), INDEX IDX_6E886F1F22010F1462CE4F5 (tag_translation_id, position), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('CREATE TABLE translations (id INT AUTO_INCREMENT NOT NULL, locale VARCHAR(10) NOT NULL, override_locale VARCHAR(10) DEFAULT NULL, name VARCHAR(255) NOT NULL, default_translation TINYINT(1) DEFAULT \'0\' NOT NULL, available TINYINT(1) DEFAULT \'1\' NOT NULL, created_at DATETIME DEFAULT NULL, updated_at DATETIME DEFAULT NULL, UNIQUE INDEX UNIQ_C6B7DA874180C698 (locale), UNIQUE INDEX UNIQ_C6B7DA873F824FD6 (override_locale), UNIQUE INDEX UNIQ_C6B7DA875E237E06 (name), INDEX IDX_C6B7DA87A58FA485 (available), INDEX IDX_C6B7DA87609A56D9 (default_translation), INDEX IDX_C6B7DA878B8E8428 (created_at), INDEX IDX_C6B7DA8743625D9F (updated_at), INDEX IDX_C6B7DA87A58FA485609A56D9 (available, default_translation), INDEX IDX_C6B7DA87A58FA4854180C698 (available, locale), INDEX IDX_C6B7DA87A58FA4853F824FD6 (available, override_locale), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('CREATE TABLE url_aliases (id INT AUTO_INCREMENT NOT NULL, ns_id INT DEFAULT NULL, alias VARCHAR(255) NOT NULL, UNIQUE INDEX UNIQ_E261ED65E16C6B94 (alias), INDEX IDX_E261ED65AA2D61 (ns_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('CREATE TABLE user_log_entries (id INT AUTO_INCREMENT NOT NULL, user_id INT DEFAULT NULL, action VARCHAR(8) NOT NULL, logged_at DATETIME NOT NULL, object_id VARCHAR(64) DEFAULT NULL, object_class VARCHAR(191) NOT NULL, version INT NOT NULL, data LONGTEXT DEFAULT NULL COMMENT \'(DC2Type:array)\', username VARCHAR(191) DEFAULT NULL, INDEX IDX_BC2E42C7A76ED395 (user_id), INDEX log_class_lookup_idx (object_class), INDEX log_date_lookup_idx (logged_at), INDEX log_user_lookup_idx (username), INDEX log_version_lookup_idx (object_id, object_class, version), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB ROW_FORMAT = DYNAMIC'); + $this->addSql('CREATE TABLE users (id INT AUTO_INCREMENT NOT NULL, chroot_id INT DEFAULT NULL, facebook_name VARCHAR(255) DEFAULT NULL, picture_url LONGTEXT DEFAULT NULL, enabled TINYINT(1) DEFAULT \'1\' NOT NULL, confirmation_token VARCHAR(255) DEFAULT NULL, password_requested_at DATETIME DEFAULT NULL, username VARCHAR(255) NOT NULL, salt VARCHAR(255) NOT NULL, password VARCHAR(255) NOT NULL, last_login DATETIME DEFAULT NULL, expired TINYINT(1) DEFAULT \'0\' NOT NULL, locked TINYINT(1) DEFAULT \'0\' NOT NULL, credentials_expires_at DATETIME DEFAULT NULL, credentials_expired TINYINT(1) DEFAULT \'0\' NOT NULL, expires_at DATETIME DEFAULT NULL, locale VARCHAR(7) DEFAULT NULL, email VARCHAR(255) NOT NULL, firstName VARCHAR(255) DEFAULT NULL, lastName VARCHAR(255) DEFAULT NULL, phone VARCHAR(255) DEFAULT NULL, company VARCHAR(255) DEFAULT NULL, job VARCHAR(255) DEFAULT NULL, birthday DATETIME DEFAULT NULL, created_at DATETIME DEFAULT NULL, updated_at DATETIME DEFAULT NULL, UNIQUE INDEX UNIQ_1483A5E9C05FB297 (confirmation_token), UNIQUE INDEX UNIQ_1483A5E9F85E0677 (username), UNIQUE INDEX UNIQ_1483A5E9E7927C74 (email), INDEX IDX_1483A5E96483A539 (chroot_id), INDEX IDX_1483A5E950F9BB84 (enabled), INDEX IDX_1483A5E9194FED4B (expired), INDEX IDX_1483A5E9F9D83E2 (expires_at), INDEX IDX_1483A5E94180C698 (locale), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('CREATE TABLE users_roles (user_id INT NOT NULL, role_id INT NOT NULL, INDEX IDX_51498A8EA76ED395 (user_id), INDEX IDX_51498A8ED60322AC (role_id), PRIMARY KEY(user_id, role_id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('CREATE TABLE users_groups (user_id INT NOT NULL, group_id INT NOT NULL, INDEX IDX_FF8AB7E0A76ED395 (user_id), INDEX IDX_FF8AB7E0FE54D947 (group_id), PRIMARY KEY(user_id, group_id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('ALTER TABLE attribute_group_translations ADD CONSTRAINT FK_5C704A6862D643B7 FOREIGN KEY (attribute_group_id) REFERENCES attribute_groups (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE attribute_group_translations ADD CONSTRAINT FK_5C704A689CAA2B25 FOREIGN KEY (translation_id) REFERENCES translations (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE attribute_translations ADD CONSTRAINT FK_4059D4A0B6E62EFA FOREIGN KEY (attribute_id) REFERENCES attributes (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE attribute_translations ADD CONSTRAINT FK_4059D4A09CAA2B25 FOREIGN KEY (translation_id) REFERENCES translations (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE attribute_value_translations ADD CONSTRAINT FK_1293849B9CAA2B25 FOREIGN KEY (translation_id) REFERENCES translations (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE attribute_value_translations ADD CONSTRAINT FK_1293849BFE4FBB82 FOREIGN KEY (attribute_value) REFERENCES attribute_values (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE attribute_values ADD CONSTRAINT FK_184662BCB6E62EFA FOREIGN KEY (attribute_id) REFERENCES attributes (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE attribute_values ADD CONSTRAINT FK_184662BC460D9FD7 FOREIGN KEY (node_id) REFERENCES nodes (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE attributes ADD CONSTRAINT FK_319B9E70FE54D947 FOREIGN KEY (group_id) REFERENCES attribute_groups (id) ON DELETE SET NULL'); + $this->addSql('ALTER TABLE attributes_documents ADD CONSTRAINT FK_67CCC9E0B6E62EFA FOREIGN KEY (attribute_id) REFERENCES attributes (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE attributes_documents ADD CONSTRAINT FK_67CCC9E0C33F7837 FOREIGN KEY (document_id) REFERENCES documents (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE custom_form_answers ADD CONSTRAINT FK_1A3BB12658AFF2B0 FOREIGN KEY (custom_form_id) REFERENCES custom_forms (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE custom_form_field_attributes ADD CONSTRAINT FK_B7133605F1D6C2D1 FOREIGN KEY (custom_form_answer_id) REFERENCES custom_form_answers (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE custom_form_field_attributes ADD CONSTRAINT FK_B71336057F13CC0F FOREIGN KEY (custom_form_field_id) REFERENCES custom_form_fields (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE custom_form_answers_documents ADD CONSTRAINT FK_E979F877C84CA2FC FOREIGN KEY (customformfieldattribute_id) REFERENCES custom_form_field_attributes (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE custom_form_answers_documents ADD CONSTRAINT FK_E979F877C33F7837 FOREIGN KEY (document_id) REFERENCES documents (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE custom_form_fields ADD CONSTRAINT FK_4A3782EC58AFF2B0 FOREIGN KEY (custom_form_id) REFERENCES custom_forms (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE documents ADD CONSTRAINT FK_A2B0728826CBD5A5 FOREIGN KEY (raw_document) REFERENCES documents (id) ON DELETE SET NULL'); + $this->addSql('ALTER TABLE documents ADD CONSTRAINT FK_A2B072882F727085 FOREIGN KEY (original) REFERENCES documents (id) ON DELETE SET NULL'); + $this->addSql('ALTER TABLE documents_translations ADD CONSTRAINT FK_5CD2F5509CAA2B25 FOREIGN KEY (translation_id) REFERENCES translations (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE documents_translations ADD CONSTRAINT FK_5CD2F550C33F7837 FOREIGN KEY (document_id) REFERENCES documents (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE folders ADD CONSTRAINT FK_FE37D30F727ACA70 FOREIGN KEY (parent_id) REFERENCES folders (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE documents_folders ADD CONSTRAINT FK_617BB29C162CB942 FOREIGN KEY (folder_id) REFERENCES folders (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE documents_folders ADD CONSTRAINT FK_617BB29CC33F7837 FOREIGN KEY (document_id) REFERENCES documents (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE folders_translations ADD CONSTRAINT FK_9F6A68B2162CB942 FOREIGN KEY (folder_id) REFERENCES folders (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE folders_translations ADD CONSTRAINT FK_9F6A68B29CAA2B25 FOREIGN KEY (translation_id) REFERENCES translations (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE groups_roles ADD CONSTRAINT FK_E79D4963FE54D947 FOREIGN KEY (group_id) REFERENCES `groups` (id)'); + $this->addSql('ALTER TABLE groups_roles ADD CONSTRAINT FK_E79D4963D60322AC FOREIGN KEY (role_id) REFERENCES roles (id)'); + $this->addSql('ALTER TABLE log ADD CONSTRAINT FK_8F3F68C5A76ED395 FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE SET NULL'); + $this->addSql('ALTER TABLE log ADD CONSTRAINT FK_8F3F68C58E831402 FOREIGN KEY (node_source_id) REFERENCES nodes_sources (id) ON DELETE SET NULL'); + $this->addSql('ALTER TABLE node_type_fields ADD CONSTRAINT FK_1D3923596344C9E1 FOREIGN KEY (node_type_id) REFERENCES node_types (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE nodes ADD CONSTRAINT FK_1D3D05FC47D04729 FOREIGN KEY (nodeType_id) REFERENCES node_types (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE nodes ADD CONSTRAINT FK_1D3D05FC3445EB91 FOREIGN KEY (parent_node_id) REFERENCES nodes (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE nodes_tags ADD CONSTRAINT FK_5B5CB38C460D9FD7 FOREIGN KEY (node_id) REFERENCES nodes (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE nodes_tags ADD CONSTRAINT FK_5B5CB38CBAD26311 FOREIGN KEY (tag_id) REFERENCES tags (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE stack_types ADD CONSTRAINT FK_DE24E53460D9FD7 FOREIGN KEY (node_id) REFERENCES nodes (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE stack_types ADD CONSTRAINT FK_DE24E53886D7EB5 FOREIGN KEY (nodetype_id) REFERENCES node_types (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE nodes_custom_forms ADD CONSTRAINT FK_4D401A0C460D9FD7 FOREIGN KEY (node_id) REFERENCES nodes (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE nodes_custom_forms ADD CONSTRAINT FK_4D401A0C58AFF2B0 FOREIGN KEY (custom_form_id) REFERENCES custom_forms (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE nodes_custom_forms ADD CONSTRAINT FK_4D401A0C47705282 FOREIGN KEY (node_type_field_id) REFERENCES node_type_fields (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE nodes_sources ADD CONSTRAINT FK_7C7DED6D460D9FD7 FOREIGN KEY (node_id) REFERENCES nodes (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE nodes_sources ADD CONSTRAINT FK_7C7DED6D9CAA2B25 FOREIGN KEY (translation_id) REFERENCES translations (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE nodes_sources_documents ADD CONSTRAINT FK_1CD104F7AA2D61 FOREIGN KEY (ns_id) REFERENCES nodes_sources (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE nodes_sources_documents ADD CONSTRAINT FK_1CD104F7C33F7837 FOREIGN KEY (document_id) REFERENCES documents (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE nodes_sources_documents ADD CONSTRAINT FK_1CD104F747705282 FOREIGN KEY (node_type_field_id) REFERENCES node_type_fields (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE nodes_to_nodes ADD CONSTRAINT FK_761F9A91FC7ADECE FOREIGN KEY (node_a_id) REFERENCES nodes (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE nodes_to_nodes ADD CONSTRAINT FK_761F9A91EECF7120 FOREIGN KEY (node_b_id) REFERENCES nodes (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE nodes_to_nodes ADD CONSTRAINT FK_761F9A9147705282 FOREIGN KEY (node_type_field_id) REFERENCES node_type_fields (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE redirections ADD CONSTRAINT FK_38F5ECE4AA2D61 FOREIGN KEY (ns_id) REFERENCES nodes_sources (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE settings ADD CONSTRAINT FK_E545A0C550DDE1BD FOREIGN KEY (setting_group_id) REFERENCES settings_groups (id) ON DELETE SET NULL'); + $this->addSql('ALTER TABLE tags ADD CONSTRAINT FK_6FBC9426F5C1A0D7 FOREIGN KEY (parent_tag_id) REFERENCES tags (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE tags_translations ADD CONSTRAINT FK_95D326DCBAD26311 FOREIGN KEY (tag_id) REFERENCES tags (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE tags_translations ADD CONSTRAINT FK_95D326DC9CAA2B25 FOREIGN KEY (translation_id) REFERENCES translations (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE tags_translations_documents ADD CONSTRAINT FK_6E886F1F22010F1 FOREIGN KEY (tag_translation_id) REFERENCES tags_translations (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE tags_translations_documents ADD CONSTRAINT FK_6E886F1FC33F7837 FOREIGN KEY (document_id) REFERENCES documents (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE url_aliases ADD CONSTRAINT FK_E261ED65AA2D61 FOREIGN KEY (ns_id) REFERENCES nodes_sources (id)'); + $this->addSql('ALTER TABLE user_log_entries ADD CONSTRAINT FK_BC2E42C7A76ED395 FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE SET NULL'); + $this->addSql('ALTER TABLE users ADD CONSTRAINT FK_1483A5E96483A539 FOREIGN KEY (chroot_id) REFERENCES nodes (id) ON DELETE SET NULL'); + $this->addSql('ALTER TABLE users_roles ADD CONSTRAINT FK_51498A8EA76ED395 FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE users_roles ADD CONSTRAINT FK_51498A8ED60322AC FOREIGN KEY (role_id) REFERENCES roles (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE users_groups ADD CONSTRAINT FK_FF8AB7E0A76ED395 FOREIGN KEY (user_id) REFERENCES users (id)'); + $this->addSql('ALTER TABLE users_groups ADD CONSTRAINT FK_FF8AB7E0FE54D947 FOREIGN KEY (group_id) REFERENCES `groups` (id)'); + } + + public function down(Schema $schema) : void + { + $this->throwIrreversibleMigrationException(); + } + + /** + * Temporary workaround + * + * @return bool + * @see https://github.com/doctrine/migrations/issues/1104 + */ + public function isTransactional(): bool + { + return false; + } +} diff --git a/lib/RoadizCoreBundle/migrations/Version20201214232628.php b/lib/RoadizCoreBundle/migrations/Version20201214232628.php new file mode 100644 index 00000000..3ec778c3 --- /dev/null +++ b/lib/RoadizCoreBundle/migrations/Version20201214232628.php @@ -0,0 +1,66 @@ +skipIf( + $this->connection->getDatabasePlatform()->getName() !== 'mysql', + 'Migration can only be executed safely on \'mysql\'.' + ); + $this->skipIf($schema->hasTable('usergroups'), 'Table `usergroups` already exists.'); + + $this->addSql('RENAME TABLE `groups` TO `usergroups`'); + $this->addSql('ALTER TABLE groups_roles DROP FOREIGN KEY FK_E79D4963FE54D947'); + $this->addSql('ALTER TABLE groups_roles ADD CONSTRAINT FK_E79D4963FE54D947 FOREIGN KEY (group_id) REFERENCES usergroups (id)'); + $this->addSql('ALTER TABLE users_groups DROP FOREIGN KEY FK_FF8AB7E0FE54D947'); + $this->addSql('ALTER TABLE users_groups ADD CONSTRAINT FK_FF8AB7E0FE54D947 FOREIGN KEY (group_id) REFERENCES usergroups (id)'); + // BC with MariaDB 10.2 + $this->addSql('DROP INDEX uniq_f06d39705e237e06 ON `usergroups`'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_98972EB45E237E06 ON `usergroups` (name)'); + // Only available on MariaDB 10.5 + // $this->addSql('ALTER TABLE `usergroups` RENAME INDEX uniq_f06d39705e237e06 TO UNIQ_98972EB45E237E06'); + } + + public function down(Schema $schema) : void + { + $this->skipIf( + $this->connection->getDatabasePlatform()->getName() !== 'mysql', + 'Migration can only be executed safely on \'mysql\'.' + ); + $this->skipIf($schema->hasTable('groups'), 'Table `groups` already exists.'); + + $this->addSql('RENAME TABLE `usergroups` TO `groups`'); + $this->addSql('ALTER TABLE groups_roles DROP FOREIGN KEY FK_E79D4963FE54D947'); + $this->addSql('ALTER TABLE groups_roles ADD CONSTRAINT FK_E79D4963FE54D947 FOREIGN KEY (group_id) REFERENCES `groups` (id)'); + $this->addSql('ALTER TABLE users_groups DROP FOREIGN KEY FK_FF8AB7E0FE54D947'); + $this->addSql('ALTER TABLE users_groups ADD CONSTRAINT FK_FF8AB7E0FE54D947 FOREIGN KEY (group_id) REFERENCES `groups` (id)'); + // BC with MariaDB 10.2 + $this->addSql('DROP INDEX UNIQ_98972EB45E237E06 ON `groups`'); + $this->addSql('CREATE UNIQUE INDEX uniq_f06d39705e237e06 ON `groups` (name)'); + // Only available on MariaDB 10.5 + // $this->addSql('ALTER TABLE `groups` RENAME INDEX UNIQ_98972EB45E237E06 TO uniq_f06d39705e237e06'); + } + + /** + * Temporary workaround + * + * @return bool + * @see https://github.com/doctrine/migrations/issues/1104 + */ + public function isTransactional(): bool + { + return false; + } +} diff --git a/lib/RoadizCoreBundle/migrations/Version20201225181256.php b/lib/RoadizCoreBundle/migrations/Version20201225181256.php new file mode 100644 index 00000000..5c22be4b --- /dev/null +++ b/lib/RoadizCoreBundle/migrations/Version20201225181256.php @@ -0,0 +1,384 @@ +skipIf( + $this->connection->getDatabasePlatform()->getName() !== 'postgresql', + 'Migration can only be executed safely on \'postgresql\'.' + ); + $this->skipIf($schema->hasTable('nodes'), 'Database has been initialized before Doctrine Migration tool.'); + + $this->addSql('CREATE SEQUENCE attribute_group_translations_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE SEQUENCE attribute_groups_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE SEQUENCE attribute_translations_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE SEQUENCE attribute_value_translations_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE SEQUENCE attribute_values_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE SEQUENCE attributes_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE SEQUENCE attributes_documents_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE SEQUENCE custom_form_answers_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE SEQUENCE custom_form_field_attributes_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE SEQUENCE custom_form_fields_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE SEQUENCE custom_forms_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE SEQUENCE documents_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE SEQUENCE documents_translations_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE SEQUENCE folders_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE SEQUENCE folders_translations_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE SEQUENCE log_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE SEQUENCE login_attempts_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE SEQUENCE node_type_fields_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE SEQUENCE node_types_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE SEQUENCE nodes_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE SEQUENCE nodes_custom_forms_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE SEQUENCE nodes_sources_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE SEQUENCE nodes_sources_documents_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE SEQUENCE nodes_to_nodes_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE SEQUENCE redirections_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE SEQUENCE roles_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE SEQUENCE settings_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE SEQUENCE settings_groups_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE SEQUENCE tags_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE SEQUENCE tags_translations_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE SEQUENCE tags_translations_documents_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE SEQUENCE translations_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE SEQUENCE url_aliases_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE SEQUENCE user_log_entries_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE SEQUENCE usergroups_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE SEQUENCE users_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE TABLE attribute_group_translations (id INT NOT NULL, attribute_group_id INT DEFAULT NULL, translation_id INT DEFAULT NULL, name VARCHAR(255) NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX IDX_5C704A6862D643B7 ON attribute_group_translations (attribute_group_id)'); + $this->addSql('CREATE INDEX IDX_5C704A689CAA2B25 ON attribute_group_translations (translation_id)'); + $this->addSql('CREATE INDEX IDX_5C704A685E237E06 ON attribute_group_translations (name)'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_5C704A6862D643B79CAA2B25 ON attribute_group_translations (attribute_group_id, translation_id)'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_5C704A685E237E069CAA2B25 ON attribute_group_translations (name, translation_id)'); + $this->addSql('CREATE TABLE attribute_groups (id INT NOT NULL, canonical_name VARCHAR(255) NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_D28C172A674D812 ON attribute_groups (canonical_name)'); + $this->addSql('CREATE INDEX IDX_D28C172A674D812 ON attribute_groups (canonical_name)'); + $this->addSql('CREATE TABLE attribute_translations (id INT NOT NULL, attribute_id INT DEFAULT NULL, translation_id INT DEFAULT NULL, label VARCHAR(255) NOT NULL, options TEXT DEFAULT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX IDX_4059D4A0B6E62EFA ON attribute_translations (attribute_id)'); + $this->addSql('CREATE INDEX IDX_4059D4A09CAA2B25 ON attribute_translations (translation_id)'); + $this->addSql('CREATE INDEX IDX_4059D4A0EA750E8 ON attribute_translations (label)'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_4059D4A0B6E62EFA9CAA2B25 ON attribute_translations (attribute_id, translation_id)'); + $this->addSql('COMMENT ON COLUMN attribute_translations.options IS \'(DC2Type:simple_array)\''); + $this->addSql('CREATE TABLE attribute_value_translations (id INT NOT NULL, translation_id INT DEFAULT NULL, attribute_value INT DEFAULT NULL, value VARCHAR(255) DEFAULT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX IDX_1293849B9CAA2B25 ON attribute_value_translations (translation_id)'); + $this->addSql('CREATE INDEX IDX_1293849BFE4FBB82 ON attribute_value_translations (attribute_value)'); + $this->addSql('CREATE INDEX IDX_1293849B1D775834 ON attribute_value_translations (value)'); + $this->addSql('CREATE INDEX IDX_1293849B9CAA2B25FE4FBB82 ON attribute_value_translations (translation_id, attribute_value)'); + $this->addSql('CREATE TABLE attribute_values (id INT NOT NULL, attribute_id INT DEFAULT NULL, node_id INT DEFAULT NULL, position DOUBLE PRECISION NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX IDX_184662BCB6E62EFA ON attribute_values (attribute_id)'); + $this->addSql('CREATE INDEX IDX_184662BC460D9FD7 ON attribute_values (node_id)'); + $this->addSql('CREATE INDEX IDX_184662BCB6E62EFA460D9FD7 ON attribute_values (attribute_id, node_id)'); + $this->addSql('CREATE TABLE attributes (id INT NOT NULL, group_id INT DEFAULT NULL, code VARCHAR(255) NOT NULL, searchable BOOLEAN DEFAULT \'false\' NOT NULL, type INT NOT NULL, color VARCHAR(7) DEFAULT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_319B9E7077153098 ON attributes (code)'); + $this->addSql('CREATE INDEX IDX_319B9E7077153098 ON attributes (code)'); + $this->addSql('CREATE INDEX IDX_319B9E708CDE5729 ON attributes (type)'); + $this->addSql('CREATE INDEX IDX_319B9E7094CD8C0D ON attributes (searchable)'); + $this->addSql('CREATE INDEX IDX_319B9E70FE54D947 ON attributes (group_id)'); + $this->addSql('CREATE TABLE attributes_documents (id INT NOT NULL, attribute_id INT DEFAULT NULL, document_id INT DEFAULT NULL, position DOUBLE PRECISION NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX IDX_67CCC9E0B6E62EFA ON attributes_documents (attribute_id)'); + $this->addSql('CREATE INDEX IDX_67CCC9E0C33F7837 ON attributes_documents (document_id)'); + $this->addSql('CREATE INDEX IDX_67CCC9E0462CE4F5 ON attributes_documents (position)'); + $this->addSql('CREATE INDEX IDX_67CCC9E0B6E62EFA462CE4F5 ON attributes_documents (attribute_id, position)'); + $this->addSql('CREATE TABLE custom_form_answers (id INT NOT NULL, custom_form_id INT DEFAULT NULL, ip VARCHAR(255) NOT NULL, submitted_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX IDX_1A3BB12658AFF2B0 ON custom_form_answers (custom_form_id)'); + $this->addSql('CREATE INDEX IDX_1A3BB126A5E3B32D ON custom_form_answers (ip)'); + $this->addSql('CREATE INDEX IDX_1A3BB1263182C73C ON custom_form_answers (submitted_at)'); + $this->addSql('CREATE TABLE custom_form_field_attributes (id INT NOT NULL, custom_form_answer_id INT DEFAULT NULL, custom_form_field_id INT DEFAULT NULL, value TEXT DEFAULT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX IDX_B7133605F1D6C2D1 ON custom_form_field_attributes (custom_form_answer_id)'); + $this->addSql('CREATE INDEX IDX_B71336057F13CC0F ON custom_form_field_attributes (custom_form_field_id)'); + $this->addSql('CREATE TABLE custom_form_answers_documents (customformfieldattribute_id INT NOT NULL, document_id INT NOT NULL, PRIMARY KEY(customformfieldattribute_id, document_id))'); + $this->addSql('CREATE INDEX IDX_E979F877C84CA2FC ON custom_form_answers_documents (customformfieldattribute_id)'); + $this->addSql('CREATE INDEX IDX_E979F877C33F7837 ON custom_form_answers_documents (document_id)'); + $this->addSql('CREATE TABLE custom_form_fields (id INT NOT NULL, custom_form_id INT DEFAULT NULL, name VARCHAR(255) NOT NULL, label VARCHAR(255) NOT NULL, placeholder VARCHAR(255) DEFAULT NULL, description TEXT DEFAULT NULL, default_values TEXT DEFAULT NULL, type INT NOT NULL, expanded BOOLEAN DEFAULT \'false\' NOT NULL, field_required BOOLEAN DEFAULT \'false\' NOT NULL, group_name VARCHAR(255) DEFAULT NULL, group_name_canonical VARCHAR(255) DEFAULT NULL, position DOUBLE PRECISION NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX IDX_4A3782EC58AFF2B0 ON custom_form_fields (custom_form_id)'); + $this->addSql('CREATE INDEX IDX_4A3782EC462CE4F5 ON custom_form_fields (position)'); + $this->addSql('CREATE INDEX IDX_4A3782EC77792576 ON custom_form_fields (group_name)'); + $this->addSql('CREATE INDEX IDX_4A3782EC8CDE5729 ON custom_form_fields (type)'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_4A3782EC5E237E0658AFF2B0 ON custom_form_fields (name, custom_form_id)'); + $this->addSql('CREATE TABLE custom_forms (id INT NOT NULL, color VARCHAR(255) DEFAULT NULL, name VARCHAR(255) NOT NULL, display_name VARCHAR(255) NOT NULL, description TEXT DEFAULT NULL, email TEXT DEFAULT NULL, open BOOLEAN DEFAULT \'true\' NOT NULL, close_date TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_3E32E39E5E237E06 ON custom_forms (name)'); + $this->addSql('CREATE INDEX IDX_3E32E39E8B8E8428 ON custom_forms (created_at)'); + $this->addSql('CREATE INDEX IDX_3E32E39E43625D9F ON custom_forms (updated_at)'); + $this->addSql('CREATE TABLE documents (id INT NOT NULL, raw_document INT DEFAULT NULL, original INT DEFAULT NULL, raw BOOLEAN DEFAULT \'false\' NOT NULL, embedId VARCHAR(255) DEFAULT NULL, embedPlatform VARCHAR(255) DEFAULT NULL, filename VARCHAR(255) DEFAULT NULL, mime_type VARCHAR(255) DEFAULT NULL, folder VARCHAR(255) NOT NULL, private BOOLEAN DEFAULT \'false\' NOT NULL, imageWidth INT DEFAULT 0 NOT NULL, imageHeight INT DEFAULT 0 NOT NULL, average_color VARCHAR(7) DEFAULT NULL, filesize INT DEFAULT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_A2B0728826CBD5A5 ON documents (raw_document)'); + $this->addSql('CREATE INDEX IDX_A2B072882F727085 ON documents (original)'); + $this->addSql('CREATE INDEX IDX_A2B072881AB3DB55 ON documents (raw)'); + $this->addSql('CREATE INDEX IDX_A2B07288D206C1D1 ON documents (private)'); + $this->addSql('CREATE INDEX IDX_A2B072881AB3DB55D206C1D1 ON documents (raw, private)'); + $this->addSql('CREATE INDEX IDX_A2B072882100AA2E ON documents (mime_type)'); + $this->addSql('CREATE TABLE documents_translations (id INT NOT NULL, translation_id INT DEFAULT NULL, document_id INT DEFAULT NULL, name VARCHAR(255) DEFAULT NULL, description TEXT DEFAULT NULL, copyright TEXT DEFAULT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX IDX_5CD2F5509CAA2B25 ON documents_translations (translation_id)'); + $this->addSql('CREATE INDEX IDX_5CD2F550C33F7837 ON documents_translations (document_id)'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_5CD2F550C33F78379CAA2B25 ON documents_translations (document_id, translation_id)'); + $this->addSql('CREATE TABLE folders (id INT NOT NULL, parent_id INT DEFAULT NULL, folder_name VARCHAR(255) NOT NULL, visible BOOLEAN DEFAULT \'true\' NOT NULL, position DOUBLE PRECISION NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_FE37D30F47BC5813 ON folders (folder_name)'); + $this->addSql('CREATE INDEX IDX_FE37D30F727ACA70 ON folders (parent_id)'); + $this->addSql('CREATE INDEX IDX_FE37D30F7AB0E859 ON folders (visible)'); + $this->addSql('CREATE INDEX IDX_FE37D30F462CE4F5 ON folders (position)'); + $this->addSql('CREATE INDEX IDX_FE37D30F8B8E8428 ON folders (created_at)'); + $this->addSql('CREATE INDEX IDX_FE37D30F43625D9F ON folders (updated_at)'); + $this->addSql('CREATE TABLE documents_folders (folder_id INT NOT NULL, document_id INT NOT NULL, PRIMARY KEY(folder_id, document_id))'); + $this->addSql('CREATE INDEX IDX_617BB29C162CB942 ON documents_folders (folder_id)'); + $this->addSql('CREATE INDEX IDX_617BB29CC33F7837 ON documents_folders (document_id)'); + $this->addSql('CREATE TABLE folders_translations (id INT NOT NULL, folder_id INT DEFAULT NULL, translation_id INT DEFAULT NULL, name VARCHAR(255) NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX IDX_9F6A68B2162CB942 ON folders_translations (folder_id)'); + $this->addSql('CREATE INDEX IDX_9F6A68B29CAA2B25 ON folders_translations (translation_id)'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_9F6A68B2162CB9429CAA2B25 ON folders_translations (folder_id, translation_id)'); + $this->addSql('CREATE TABLE log (id INT NOT NULL, user_id INT DEFAULT NULL, node_source_id INT DEFAULT NULL, username VARCHAR(255) DEFAULT NULL, message TEXT NOT NULL, level INT NOT NULL, datetime TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, client_ip VARCHAR(255) DEFAULT NULL, channel VARCHAR(255) DEFAULT NULL, additional_data JSON DEFAULT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX IDX_8F3F68C5A76ED395 ON log (user_id)'); + $this->addSql('CREATE INDEX IDX_8F3F68C58E831402 ON log (node_source_id)'); + $this->addSql('CREATE INDEX IDX_8F3F68C593F3C6CA ON log (datetime)'); + $this->addSql('CREATE INDEX IDX_8F3F68C59AEACC13 ON log (level)'); + $this->addSql('CREATE INDEX IDX_8F3F68C5F85E0677 ON log (username)'); + $this->addSql('CREATE INDEX IDX_8F3F68C5A2F98E47 ON log (channel)'); + $this->addSql('CREATE TABLE login_attempts (id INT NOT NULL, ip_address VARCHAR(50) DEFAULT NULL, date TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, blocks_login_until TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, username VARCHAR(255) NOT NULL, attempt_count INT DEFAULT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX IDX_9163C7FBF85E0677 ON login_attempts (username)'); + $this->addSql('CREATE INDEX IDX_9163C7FBEFF8A4EEF85E0677 ON login_attempts (blocks_login_until, username)'); + $this->addSql('CREATE INDEX IDX_9163C7FBEFF8A4EEF85E067722FFD58C ON login_attempts (blocks_login_until, username, ip_address)'); + $this->addSql('COMMENT ON COLUMN login_attempts.date IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('CREATE TABLE node_type_fields (id INT NOT NULL, node_type_id INT DEFAULT NULL, name VARCHAR(255) NOT NULL, label VARCHAR(255) NOT NULL, placeholder VARCHAR(255) DEFAULT NULL, description TEXT DEFAULT NULL, default_values TEXT DEFAULT NULL, type INT NOT NULL, expanded BOOLEAN DEFAULT \'false\' NOT NULL, universal BOOLEAN DEFAULT \'false\' NOT NULL, exclude_from_search BOOLEAN DEFAULT \'false\' NOT NULL, min_length INT DEFAULT NULL, max_length INT DEFAULT NULL, indexed BOOLEAN DEFAULT \'false\' NOT NULL, visible BOOLEAN DEFAULT \'true\' NOT NULL, group_name VARCHAR(255) DEFAULT NULL, group_name_canonical VARCHAR(255) DEFAULT NULL, position DOUBLE PRECISION NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX IDX_1D3923596344C9E1 ON node_type_fields (node_type_id)'); + $this->addSql('CREATE INDEX IDX_1D3923597AB0E859 ON node_type_fields (visible)'); + $this->addSql('CREATE INDEX IDX_1D392359D9416D95 ON node_type_fields (indexed)'); + $this->addSql('CREATE INDEX IDX_1D392359462CE4F5 ON node_type_fields (position)'); + $this->addSql('CREATE INDEX IDX_1D39235977792576 ON node_type_fields (group_name)'); + $this->addSql('CREATE INDEX IDX_1D3923594BAF07A4 ON node_type_fields (group_name_canonical)'); + $this->addSql('CREATE INDEX IDX_1D3923598CDE5729 ON node_type_fields (type)'); + $this->addSql('CREATE INDEX IDX_1D392359A4B8F6E1 ON node_type_fields (universal)'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_1D3923595E237E066344C9E1 ON node_type_fields (name, node_type_id)'); + $this->addSql('CREATE TABLE node_types (id INT NOT NULL, name VARCHAR(255) NOT NULL, display_name VARCHAR(255) NOT NULL, description TEXT DEFAULT NULL, visible BOOLEAN DEFAULT \'true\' NOT NULL, publishable BOOLEAN DEFAULT \'false\' NOT NULL, reachable BOOLEAN DEFAULT \'true\' NOT NULL, hiding_nodes BOOLEAN DEFAULT \'false\' NOT NULL, hiding_non_reachable_nodes BOOLEAN DEFAULT \'false\' NOT NULL, color VARCHAR(255) DEFAULT NULL, default_ttl INT DEFAULT 0 NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_409B1BCC5E237E06 ON node_types (name)'); + $this->addSql('CREATE INDEX IDX_409B1BCC7AB0E859 ON node_types (visible)'); + $this->addSql('CREATE INDEX IDX_409B1BCC7697C594 ON node_types (publishable)'); + $this->addSql('CREATE INDEX IDX_409B1BCCFB696FF0 ON node_types (hiding_nodes)'); + $this->addSql('CREATE INDEX IDX_409B1BCC5A3C14C7 ON node_types (hiding_non_reachable_nodes)'); + $this->addSql('CREATE INDEX IDX_409B1BCC96ED695F ON node_types (reachable)'); + $this->addSql('CREATE TABLE nodes (id INT NOT NULL, parent_node_id INT DEFAULT NULL, node_name VARCHAR(255) NOT NULL, dynamic_node_name BOOLEAN DEFAULT \'true\' NOT NULL, home BOOLEAN DEFAULT \'false\' NOT NULL, visible BOOLEAN DEFAULT \'true\' NOT NULL, status INT NOT NULL, ttl INT DEFAULT 0 NOT NULL, locked BOOLEAN DEFAULT \'false\' NOT NULL, priority NUMERIC(2, 1) NOT NULL, hide_children BOOLEAN DEFAULT \'false\' NOT NULL, sterile BOOLEAN DEFAULT \'false\' NOT NULL, children_order VARCHAR(255) NOT NULL, children_order_direction VARCHAR(4) NOT NULL, position DOUBLE PRECISION NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, nodeType_id INT DEFAULT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_1D3D05FC9987F390 ON nodes (node_name)'); + $this->addSql('CREATE INDEX IDX_1D3D05FC47D04729 ON nodes (nodeType_id)'); + $this->addSql('CREATE INDEX IDX_1D3D05FC3445EB91 ON nodes (parent_node_id)'); + $this->addSql('CREATE INDEX IDX_1D3D05FC7AB0E859 ON nodes (visible)'); + $this->addSql('CREATE INDEX IDX_1D3D05FC7B00651C ON nodes (status)'); + $this->addSql('CREATE INDEX IDX_1D3D05FCEAD2C891 ON nodes (locked)'); + $this->addSql('CREATE INDEX IDX_1D3D05FCF32D8BE6 ON nodes (sterile)'); + $this->addSql('CREATE INDEX IDX_1D3D05FC462CE4F5 ON nodes (position)'); + $this->addSql('CREATE INDEX IDX_1D3D05FC8B8E8428 ON nodes (created_at)'); + $this->addSql('CREATE INDEX IDX_1D3D05FC43625D9F ON nodes (updated_at)'); + $this->addSql('CREATE INDEX IDX_1D3D05FC50E2D3D2 ON nodes (hide_children)'); + $this->addSql('CREATE INDEX IDX_1D3D05FC9987F3907B00651C ON nodes (node_name, status)'); + $this->addSql('CREATE INDEX IDX_1D3D05FC7AB0E8597B00651C ON nodes (visible, status)'); + $this->addSql('CREATE INDEX IDX_1D3D05FC7AB0E8597B00651C3445EB91 ON nodes (visible, status, parent_node_id)'); + $this->addSql('CREATE INDEX IDX_1D3D05FC7AB0E8593445EB91 ON nodes (visible, parent_node_id)'); + $this->addSql('CREATE INDEX IDX_1D3D05FC71D60CD0 ON nodes (home)'); + $this->addSql('CREATE TABLE nodes_tags (node_id INT NOT NULL, tag_id INT NOT NULL, PRIMARY KEY(node_id, tag_id))'); + $this->addSql('CREATE INDEX IDX_5B5CB38C460D9FD7 ON nodes_tags (node_id)'); + $this->addSql('CREATE INDEX IDX_5B5CB38CBAD26311 ON nodes_tags (tag_id)'); + $this->addSql('CREATE TABLE stack_types (node_id INT NOT NULL, nodetype_id INT NOT NULL, PRIMARY KEY(node_id, nodetype_id))'); + $this->addSql('CREATE INDEX IDX_DE24E53460D9FD7 ON stack_types (node_id)'); + $this->addSql('CREATE INDEX IDX_DE24E53886D7EB5 ON stack_types (nodetype_id)'); + $this->addSql('CREATE TABLE nodes_custom_forms (id INT NOT NULL, node_id INT DEFAULT NULL, custom_form_id INT DEFAULT NULL, node_type_field_id INT DEFAULT NULL, position DOUBLE PRECISION NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX IDX_4D401A0C460D9FD7 ON nodes_custom_forms (node_id)'); + $this->addSql('CREATE INDEX IDX_4D401A0C58AFF2B0 ON nodes_custom_forms (custom_form_id)'); + $this->addSql('CREATE INDEX IDX_4D401A0C47705282 ON nodes_custom_forms (node_type_field_id)'); + $this->addSql('CREATE INDEX IDX_4D401A0C462CE4F5 ON nodes_custom_forms (position)'); + $this->addSql('CREATE TABLE nodes_sources (id INT NOT NULL, node_id INT DEFAULT NULL, translation_id INT DEFAULT NULL, title VARCHAR(255) DEFAULT NULL, published_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, meta_title VARCHAR(255) NOT NULL, meta_keywords TEXT NOT NULL, meta_description TEXT NOT NULL, discr VARCHAR(255) NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX IDX_7C7DED6D460D9FD7 ON nodes_sources (node_id)'); + $this->addSql('CREATE INDEX IDX_7C7DED6D9CAA2B25 ON nodes_sources (translation_id)'); + $this->addSql('CREATE INDEX IDX_7C7DED6D4AD26064 ON nodes_sources (discr)'); + $this->addSql('CREATE INDEX IDX_7C7DED6D4AD260649CAA2B25 ON nodes_sources (discr, translation_id)'); + $this->addSql('CREATE INDEX IDX_7C7DED6DE0D4FDE14AD260649CAA2B25 ON nodes_sources (published_at, discr, translation_id)'); + $this->addSql('CREATE INDEX IDX_7C7DED6D2B36786B ON nodes_sources (title)'); + $this->addSql('CREATE INDEX IDX_7C7DED6DE0D4FDE1 ON nodes_sources (published_at)'); + $this->addSql('CREATE INDEX IDX_7C7DED6DE0D4FDE19CAA2B25 ON nodes_sources (published_at, translation_id)'); + $this->addSql('CREATE INDEX IDX_7C7DED6D460D9FD79CAA2B25E0D4FDE1 ON nodes_sources (node_id, translation_id, published_at)'); + $this->addSql('CREATE INDEX IDX_7C7DED6D2B36786BE0D4FDE1 ON nodes_sources (title, published_at)'); + $this->addSql('CREATE INDEX IDX_7C7DED6D2B36786BE0D4FDE19CAA2B25 ON nodes_sources (title, published_at, translation_id)'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_7C7DED6D460D9FD79CAA2B25 ON nodes_sources (node_id, translation_id)'); + $this->addSql('CREATE TABLE nodes_sources_documents (id INT NOT NULL, ns_id INT DEFAULT NULL, document_id INT DEFAULT NULL, node_type_field_id INT DEFAULT NULL, position DOUBLE PRECISION NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX IDX_1CD104F7AA2D61 ON nodes_sources_documents (ns_id)'); + $this->addSql('CREATE INDEX IDX_1CD104F7C33F7837 ON nodes_sources_documents (document_id)'); + $this->addSql('CREATE INDEX IDX_1CD104F747705282 ON nodes_sources_documents (node_type_field_id)'); + $this->addSql('CREATE INDEX IDX_1CD104F7462CE4F5 ON nodes_sources_documents (position)'); + $this->addSql('CREATE INDEX IDX_1CD104F7AA2D6147705282 ON nodes_sources_documents (ns_id, node_type_field_id)'); + $this->addSql('CREATE INDEX IDX_1CD104F7AA2D6147705282462CE4F5 ON nodes_sources_documents (ns_id, node_type_field_id, position)'); + $this->addSql('CREATE TABLE nodes_to_nodes (id INT NOT NULL, node_a_id INT DEFAULT NULL, node_b_id INT DEFAULT NULL, node_type_field_id INT DEFAULT NULL, position DOUBLE PRECISION NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX IDX_761F9A91FC7ADECE ON nodes_to_nodes (node_a_id)'); + $this->addSql('CREATE INDEX IDX_761F9A91EECF7120 ON nodes_to_nodes (node_b_id)'); + $this->addSql('CREATE INDEX IDX_761F9A9147705282 ON nodes_to_nodes (node_type_field_id)'); + $this->addSql('CREATE INDEX IDX_761F9A91462CE4F5 ON nodes_to_nodes (position)'); + $this->addSql('CREATE INDEX IDX_761F9A91FC7ADECE47705282 ON nodes_to_nodes (node_a_id, node_type_field_id)'); + $this->addSql('CREATE INDEX IDX_761F9A91FC7ADECE47705282462CE4F5 ON nodes_to_nodes (node_a_id, node_type_field_id, position)'); + $this->addSql('CREATE INDEX IDX_761F9A91EECF712047705282 ON nodes_to_nodes (node_b_id, node_type_field_id)'); + $this->addSql('CREATE INDEX IDX_761F9A91EECF712047705282462CE4F5 ON nodes_to_nodes (node_b_id, node_type_field_id, position)'); + $this->addSql('CREATE TABLE redirections (id INT NOT NULL, ns_id INT DEFAULT NULL, query VARCHAR(255) NOT NULL, redirectUri VARCHAR(255) DEFAULT NULL, type INT NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_38F5ECE424BDB5EB ON redirections (query)'); + $this->addSql('CREATE INDEX IDX_38F5ECE4AA2D61 ON redirections (ns_id)'); + $this->addSql('CREATE INDEX IDX_38F5ECE48B8E8428 ON redirections (created_at)'); + $this->addSql('CREATE INDEX IDX_38F5ECE443625D9F ON redirections (updated_at)'); + $this->addSql('CREATE TABLE roles (id INT NOT NULL, name VARCHAR(255) NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_B63E2EC75E237E06 ON roles (name)'); + $this->addSql('CREATE TABLE settings (id INT NOT NULL, setting_group_id INT DEFAULT NULL, name VARCHAR(255) NOT NULL, description TEXT DEFAULT NULL, value TEXT DEFAULT NULL, visible BOOLEAN DEFAULT \'true\' NOT NULL, encrypted BOOLEAN DEFAULT \'false\' NOT NULL, type INT NOT NULL, defaultValues TEXT DEFAULT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_E545A0C55E237E06 ON settings (name)'); + $this->addSql('CREATE INDEX IDX_E545A0C550DDE1BD ON settings (setting_group_id)'); + $this->addSql('CREATE INDEX IDX_E545A0C58CDE5729 ON settings (type)'); + $this->addSql('CREATE INDEX IDX_E545A0C55E237E06 ON settings (name)'); + $this->addSql('CREATE INDEX IDX_E545A0C57AB0E859 ON settings (visible)'); + $this->addSql('CREATE TABLE settings_groups (id INT NOT NULL, name VARCHAR(255) NOT NULL, in_menu BOOLEAN DEFAULT \'false\' NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_FFD519025E237E06 ON settings_groups (name)'); + $this->addSql('CREATE TABLE tags (id INT NOT NULL, parent_tag_id INT DEFAULT NULL, color VARCHAR(7) DEFAULT \'#000000\' NOT NULL, tag_name VARCHAR(255) NOT NULL, visible BOOLEAN DEFAULT \'true\' NOT NULL, children_order VARCHAR(255) DEFAULT \'position\' NOT NULL, children_order_direction VARCHAR(4) DEFAULT \'ASC\' NOT NULL, locked BOOLEAN DEFAULT \'false\' NOT NULL, position DOUBLE PRECISION NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_6FBC9426B02CC1B0 ON tags (tag_name)'); + $this->addSql('CREATE INDEX IDX_6FBC9426F5C1A0D7 ON tags (parent_tag_id)'); + $this->addSql('CREATE INDEX IDX_6FBC94267AB0E859 ON tags (visible)'); + $this->addSql('CREATE INDEX IDX_6FBC9426EAD2C891 ON tags (locked)'); + $this->addSql('CREATE INDEX IDX_6FBC9426462CE4F5 ON tags (position)'); + $this->addSql('CREATE INDEX IDX_6FBC94268B8E8428 ON tags (created_at)'); + $this->addSql('CREATE INDEX IDX_6FBC942643625D9F ON tags (updated_at)'); + $this->addSql('CREATE INDEX IDX_6FBC9426F5C1A0D77AB0E859 ON tags (parent_tag_id, visible)'); + $this->addSql('CREATE TABLE tags_translations (id INT NOT NULL, tag_id INT DEFAULT NULL, translation_id INT DEFAULT NULL, name VARCHAR(255) NOT NULL, description TEXT DEFAULT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX IDX_95D326DCBAD26311 ON tags_translations (tag_id)'); + $this->addSql('CREATE INDEX IDX_95D326DC9CAA2B25 ON tags_translations (translation_id)'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_95D326DCBAD263119CAA2B25 ON tags_translations (tag_id, translation_id)'); + $this->addSql('CREATE TABLE tags_translations_documents (id INT NOT NULL, tag_translation_id INT DEFAULT NULL, document_id INT DEFAULT NULL, position DOUBLE PRECISION NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX IDX_6E886F1F22010F1 ON tags_translations_documents (tag_translation_id)'); + $this->addSql('CREATE INDEX IDX_6E886F1FC33F7837 ON tags_translations_documents (document_id)'); + $this->addSql('CREATE INDEX IDX_6E886F1F462CE4F5 ON tags_translations_documents (position)'); + $this->addSql('CREATE INDEX IDX_6E886F1F22010F1462CE4F5 ON tags_translations_documents (tag_translation_id, position)'); + $this->addSql('CREATE TABLE translations (id INT NOT NULL, locale VARCHAR(10) NOT NULL, override_locale VARCHAR(10) DEFAULT NULL, name VARCHAR(255) NOT NULL, default_translation BOOLEAN DEFAULT \'false\' NOT NULL, available BOOLEAN DEFAULT \'true\' NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_C6B7DA874180C698 ON translations (locale)'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_C6B7DA873F824FD6 ON translations (override_locale)'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_C6B7DA875E237E06 ON translations (name)'); + $this->addSql('CREATE INDEX IDX_C6B7DA87A58FA485 ON translations (available)'); + $this->addSql('CREATE INDEX IDX_C6B7DA87609A56D9 ON translations (default_translation)'); + $this->addSql('CREATE INDEX IDX_C6B7DA878B8E8428 ON translations (created_at)'); + $this->addSql('CREATE INDEX IDX_C6B7DA8743625D9F ON translations (updated_at)'); + $this->addSql('CREATE INDEX IDX_C6B7DA87A58FA485609A56D9 ON translations (available, default_translation)'); + $this->addSql('CREATE INDEX IDX_C6B7DA87A58FA4854180C698 ON translations (available, locale)'); + $this->addSql('CREATE INDEX IDX_C6B7DA87A58FA4853F824FD6 ON translations (available, override_locale)'); + $this->addSql('CREATE TABLE url_aliases (id INT NOT NULL, ns_id INT DEFAULT NULL, alias VARCHAR(255) NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_E261ED65E16C6B94 ON url_aliases (alias)'); + $this->addSql('CREATE INDEX IDX_E261ED65AA2D61 ON url_aliases (ns_id)'); + $this->addSql('CREATE TABLE user_log_entries (id INT NOT NULL, user_id INT DEFAULT NULL, action VARCHAR(8) NOT NULL, logged_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, object_id VARCHAR(64) DEFAULT NULL, object_class VARCHAR(191) NOT NULL, version INT NOT NULL, data TEXT DEFAULT NULL, username VARCHAR(191) DEFAULT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX IDX_BC2E42C7A76ED395 ON user_log_entries (user_id)'); + $this->addSql('CREATE INDEX log_class_lookup_idx ON user_log_entries (object_class)'); + $this->addSql('CREATE INDEX log_date_lookup_idx ON user_log_entries (logged_at)'); + $this->addSql('CREATE INDEX log_user_lookup_idx ON user_log_entries (username)'); + $this->addSql('CREATE INDEX log_version_lookup_idx ON user_log_entries (object_id, object_class, version)'); + $this->addSql('COMMENT ON COLUMN user_log_entries.data IS \'(DC2Type:array)\''); + $this->addSql('CREATE TABLE usergroups (id INT NOT NULL, name VARCHAR(255) NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_98972EB45E237E06 ON usergroups (name)'); + $this->addSql('CREATE TABLE groups_roles (group_id INT NOT NULL, role_id INT NOT NULL, PRIMARY KEY(group_id, role_id))'); + $this->addSql('CREATE INDEX IDX_E79D4963FE54D947 ON groups_roles (group_id)'); + $this->addSql('CREATE INDEX IDX_E79D4963D60322AC ON groups_roles (role_id)'); + $this->addSql('CREATE TABLE users (id INT NOT NULL, chroot_id INT DEFAULT NULL, facebook_name VARCHAR(255) DEFAULT NULL, picture_url TEXT DEFAULT NULL, enabled BOOLEAN DEFAULT \'true\' NOT NULL, confirmation_token VARCHAR(255) DEFAULT NULL, password_requested_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, username VARCHAR(255) NOT NULL, salt VARCHAR(255) NOT NULL, password VARCHAR(255) NOT NULL, last_login TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, expired BOOLEAN DEFAULT \'false\' NOT NULL, locked BOOLEAN DEFAULT \'false\' NOT NULL, credentials_expires_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, credentials_expired BOOLEAN DEFAULT \'false\' NOT NULL, expires_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, locale VARCHAR(7) DEFAULT NULL, email VARCHAR(255) NOT NULL, firstName VARCHAR(255) DEFAULT NULL, lastName VARCHAR(255) DEFAULT NULL, phone VARCHAR(255) DEFAULT NULL, company VARCHAR(255) DEFAULT NULL, job VARCHAR(255) DEFAULT NULL, birthday TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_1483A5E9C05FB297 ON users (confirmation_token)'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_1483A5E9F85E0677 ON users (username)'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_1483A5E9E7927C74 ON users (email)'); + $this->addSql('CREATE INDEX IDX_1483A5E96483A539 ON users (chroot_id)'); + $this->addSql('CREATE INDEX IDX_1483A5E950F9BB84 ON users (enabled)'); + $this->addSql('CREATE INDEX IDX_1483A5E9194FED4B ON users (expired)'); + $this->addSql('CREATE INDEX IDX_1483A5E9F9D83E2 ON users (expires_at)'); + $this->addSql('CREATE INDEX IDX_1483A5E94180C698 ON users (locale)'); + $this->addSql('CREATE TABLE users_roles (user_id INT NOT NULL, role_id INT NOT NULL, PRIMARY KEY(user_id, role_id))'); + $this->addSql('CREATE INDEX IDX_51498A8EA76ED395 ON users_roles (user_id)'); + $this->addSql('CREATE INDEX IDX_51498A8ED60322AC ON users_roles (role_id)'); + $this->addSql('CREATE TABLE users_groups (user_id INT NOT NULL, group_id INT NOT NULL, PRIMARY KEY(user_id, group_id))'); + $this->addSql('CREATE INDEX IDX_FF8AB7E0A76ED395 ON users_groups (user_id)'); + $this->addSql('CREATE INDEX IDX_FF8AB7E0FE54D947 ON users_groups (group_id)'); + $this->addSql('ALTER TABLE attribute_group_translations ADD CONSTRAINT FK_5C704A6862D643B7 FOREIGN KEY (attribute_group_id) REFERENCES attribute_groups (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE attribute_group_translations ADD CONSTRAINT FK_5C704A689CAA2B25 FOREIGN KEY (translation_id) REFERENCES translations (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE attribute_translations ADD CONSTRAINT FK_4059D4A0B6E62EFA FOREIGN KEY (attribute_id) REFERENCES attributes (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE attribute_translations ADD CONSTRAINT FK_4059D4A09CAA2B25 FOREIGN KEY (translation_id) REFERENCES translations (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE attribute_value_translations ADD CONSTRAINT FK_1293849B9CAA2B25 FOREIGN KEY (translation_id) REFERENCES translations (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE attribute_value_translations ADD CONSTRAINT FK_1293849BFE4FBB82 FOREIGN KEY (attribute_value) REFERENCES attribute_values (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE attribute_values ADD CONSTRAINT FK_184662BCB6E62EFA FOREIGN KEY (attribute_id) REFERENCES attributes (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE attribute_values ADD CONSTRAINT FK_184662BC460D9FD7 FOREIGN KEY (node_id) REFERENCES nodes (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE attributes ADD CONSTRAINT FK_319B9E70FE54D947 FOREIGN KEY (group_id) REFERENCES attribute_groups (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE attributes_documents ADD CONSTRAINT FK_67CCC9E0B6E62EFA FOREIGN KEY (attribute_id) REFERENCES attributes (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE attributes_documents ADD CONSTRAINT FK_67CCC9E0C33F7837 FOREIGN KEY (document_id) REFERENCES documents (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE custom_form_answers ADD CONSTRAINT FK_1A3BB12658AFF2B0 FOREIGN KEY (custom_form_id) REFERENCES custom_forms (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE custom_form_field_attributes ADD CONSTRAINT FK_B7133605F1D6C2D1 FOREIGN KEY (custom_form_answer_id) REFERENCES custom_form_answers (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE custom_form_field_attributes ADD CONSTRAINT FK_B71336057F13CC0F FOREIGN KEY (custom_form_field_id) REFERENCES custom_form_fields (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE custom_form_answers_documents ADD CONSTRAINT FK_E979F877C84CA2FC FOREIGN KEY (customformfieldattribute_id) REFERENCES custom_form_field_attributes (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE custom_form_answers_documents ADD CONSTRAINT FK_E979F877C33F7837 FOREIGN KEY (document_id) REFERENCES documents (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE custom_form_fields ADD CONSTRAINT FK_4A3782EC58AFF2B0 FOREIGN KEY (custom_form_id) REFERENCES custom_forms (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE documents ADD CONSTRAINT FK_A2B0728826CBD5A5 FOREIGN KEY (raw_document) REFERENCES documents (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE documents ADD CONSTRAINT FK_A2B072882F727085 FOREIGN KEY (original) REFERENCES documents (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE documents_translations ADD CONSTRAINT FK_5CD2F5509CAA2B25 FOREIGN KEY (translation_id) REFERENCES translations (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE documents_translations ADD CONSTRAINT FK_5CD2F550C33F7837 FOREIGN KEY (document_id) REFERENCES documents (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE folders ADD CONSTRAINT FK_FE37D30F727ACA70 FOREIGN KEY (parent_id) REFERENCES folders (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE documents_folders ADD CONSTRAINT FK_617BB29C162CB942 FOREIGN KEY (folder_id) REFERENCES folders (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE documents_folders ADD CONSTRAINT FK_617BB29CC33F7837 FOREIGN KEY (document_id) REFERENCES documents (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE folders_translations ADD CONSTRAINT FK_9F6A68B2162CB942 FOREIGN KEY (folder_id) REFERENCES folders (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE folders_translations ADD CONSTRAINT FK_9F6A68B29CAA2B25 FOREIGN KEY (translation_id) REFERENCES translations (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE log ADD CONSTRAINT FK_8F3F68C5A76ED395 FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE log ADD CONSTRAINT FK_8F3F68C58E831402 FOREIGN KEY (node_source_id) REFERENCES nodes_sources (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE node_type_fields ADD CONSTRAINT FK_1D3923596344C9E1 FOREIGN KEY (node_type_id) REFERENCES node_types (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE nodes ADD CONSTRAINT FK_1D3D05FC47D04729 FOREIGN KEY (nodeType_id) REFERENCES node_types (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE nodes ADD CONSTRAINT FK_1D3D05FC3445EB91 FOREIGN KEY (parent_node_id) REFERENCES nodes (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE nodes_tags ADD CONSTRAINT FK_5B5CB38C460D9FD7 FOREIGN KEY (node_id) REFERENCES nodes (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE nodes_tags ADD CONSTRAINT FK_5B5CB38CBAD26311 FOREIGN KEY (tag_id) REFERENCES tags (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE stack_types ADD CONSTRAINT FK_DE24E53460D9FD7 FOREIGN KEY (node_id) REFERENCES nodes (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE stack_types ADD CONSTRAINT FK_DE24E53886D7EB5 FOREIGN KEY (nodetype_id) REFERENCES node_types (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE nodes_custom_forms ADD CONSTRAINT FK_4D401A0C460D9FD7 FOREIGN KEY (node_id) REFERENCES nodes (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE nodes_custom_forms ADD CONSTRAINT FK_4D401A0C58AFF2B0 FOREIGN KEY (custom_form_id) REFERENCES custom_forms (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE nodes_custom_forms ADD CONSTRAINT FK_4D401A0C47705282 FOREIGN KEY (node_type_field_id) REFERENCES node_type_fields (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE nodes_sources ADD CONSTRAINT FK_7C7DED6D460D9FD7 FOREIGN KEY (node_id) REFERENCES nodes (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE nodes_sources ADD CONSTRAINT FK_7C7DED6D9CAA2B25 FOREIGN KEY (translation_id) REFERENCES translations (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE nodes_sources_documents ADD CONSTRAINT FK_1CD104F7AA2D61 FOREIGN KEY (ns_id) REFERENCES nodes_sources (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE nodes_sources_documents ADD CONSTRAINT FK_1CD104F7C33F7837 FOREIGN KEY (document_id) REFERENCES documents (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE nodes_sources_documents ADD CONSTRAINT FK_1CD104F747705282 FOREIGN KEY (node_type_field_id) REFERENCES node_type_fields (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE nodes_to_nodes ADD CONSTRAINT FK_761F9A91FC7ADECE FOREIGN KEY (node_a_id) REFERENCES nodes (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE nodes_to_nodes ADD CONSTRAINT FK_761F9A91EECF7120 FOREIGN KEY (node_b_id) REFERENCES nodes (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE nodes_to_nodes ADD CONSTRAINT FK_761F9A9147705282 FOREIGN KEY (node_type_field_id) REFERENCES node_type_fields (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE redirections ADD CONSTRAINT FK_38F5ECE4AA2D61 FOREIGN KEY (ns_id) REFERENCES nodes_sources (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE settings ADD CONSTRAINT FK_E545A0C550DDE1BD FOREIGN KEY (setting_group_id) REFERENCES settings_groups (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE tags ADD CONSTRAINT FK_6FBC9426F5C1A0D7 FOREIGN KEY (parent_tag_id) REFERENCES tags (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE tags_translations ADD CONSTRAINT FK_95D326DCBAD26311 FOREIGN KEY (tag_id) REFERENCES tags (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE tags_translations ADD CONSTRAINT FK_95D326DC9CAA2B25 FOREIGN KEY (translation_id) REFERENCES translations (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE tags_translations_documents ADD CONSTRAINT FK_6E886F1F22010F1 FOREIGN KEY (tag_translation_id) REFERENCES tags_translations (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE tags_translations_documents ADD CONSTRAINT FK_6E886F1FC33F7837 FOREIGN KEY (document_id) REFERENCES documents (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE url_aliases ADD CONSTRAINT FK_E261ED65AA2D61 FOREIGN KEY (ns_id) REFERENCES nodes_sources (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE user_log_entries ADD CONSTRAINT FK_BC2E42C7A76ED395 FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE groups_roles ADD CONSTRAINT FK_E79D4963FE54D947 FOREIGN KEY (group_id) REFERENCES usergroups (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE groups_roles ADD CONSTRAINT FK_E79D4963D60322AC FOREIGN KEY (role_id) REFERENCES roles (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE users ADD CONSTRAINT FK_1483A5E96483A539 FOREIGN KEY (chroot_id) REFERENCES nodes (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE users_roles ADD CONSTRAINT FK_51498A8EA76ED395 FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE users_roles ADD CONSTRAINT FK_51498A8ED60322AC FOREIGN KEY (role_id) REFERENCES roles (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE users_groups ADD CONSTRAINT FK_FF8AB7E0A76ED395 FOREIGN KEY (user_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE users_groups ADD CONSTRAINT FK_FF8AB7E0FE54D947 FOREIGN KEY (group_id) REFERENCES usergroups (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + } + + public function down(Schema $schema) : void + { + $this->throwIrreversibleMigrationException(); + } + + /** + * Temporary workaround + * + * @return bool + * @see https://github.com/doctrine/migrations/issues/1104 + */ + public function isTransactional(): bool + { + return false; + } +} diff --git a/lib/RoadizCoreBundle/migrations/Version20210423072744.php b/lib/RoadizCoreBundle/migrations/Version20210423072744.php new file mode 100644 index 00000000..2a92eff7 --- /dev/null +++ b/lib/RoadizCoreBundle/migrations/Version20210423072744.php @@ -0,0 +1,34 @@ +addSql('ALTER TABLE documents ADD duration INT DEFAULT 0 NOT NULL'); + } + + public function down(Schema $schema) : void + { + $this->addSql('ALTER TABLE documents DROP duration'); + } + + public function isTransactional(): bool + { + return false; + } +} diff --git a/lib/RoadizCoreBundle/migrations/Version20210423161606.php b/lib/RoadizCoreBundle/migrations/Version20210423161606.php new file mode 100644 index 00000000..56ae3af1 --- /dev/null +++ b/lib/RoadizCoreBundle/migrations/Version20210423161606.php @@ -0,0 +1,71 @@ +addSql('CREATE INDEX document_created_at ON documents (created_at)'); + $this->addSql('CREATE INDEX document_updated_at ON documents (updated_at)'); + $this->addSql('CREATE INDEX document_raw_created_at ON documents (raw, created_at)'); + $this->addSql('CREATE INDEX document_embed_platform ON documents (embedPlatform)'); + $this->addSql('CREATE INDEX folder_parent_position ON folders (parent_id, position)'); + $this->addSql('CREATE INDEX log_ns_datetime ON log (node_source_id, datetime)'); + $this->addSql('CREATE INDEX log_username_datetime ON log (username, datetime)'); + $this->addSql('CREATE INDEX log_user_datetime ON log (user_id, datetime)'); + $this->addSql('CREATE INDEX log_level_datetime ON log (level, datetime)'); + $this->addSql('CREATE INDEX log_channel_datetime ON log (channel, datetime)'); + $this->addSql('CREATE INDEX ns_node_translation_discr ON nodes_sources (node_id, discr, translation_id)'); + $this->addSql('CREATE INDEX tag_parent_position ON tags (parent_tag_id, position)'); + } + + public function down(Schema $schema) : void + { + if ($this->connection->getDatabasePlatform()->getName() === 'postgresql') { + $this->addSql('DROP INDEX IF EXISTS document_created_at'); + $this->addSql('DROP INDEX IF EXISTS document_updated_at'); + $this->addSql('DROP INDEX IF EXISTS document_raw_created_at'); + $this->addSql('DROP INDEX IF EXISTS document_embed_platform'); + $this->addSql('DROP INDEX IF EXISTS folder_parent_position'); + $this->addSql('DROP INDEX IF EXISTS log_ns_datetime'); + $this->addSql('DROP INDEX IF EXISTS log_username_datetime'); + $this->addSql('DROP INDEX IF EXISTS log_user_datetime'); + $this->addSql('DROP INDEX IF EXISTS log_level_datetime'); + $this->addSql('DROP INDEX IF EXISTS log_channel_datetime'); + $this->addSql('DROP INDEX IF EXISTS ns_node_translation_discr'); + $this->addSql('DROP INDEX IF EXISTS tag_parent_position'); + } else { + $this->addSql('DROP INDEX document_created_at ON documents'); + $this->addSql('DROP INDEX document_updated_at ON documents'); + $this->addSql('DROP INDEX document_raw_created_at ON documents'); + $this->addSql('DROP INDEX document_embed_platform ON documents'); + $this->addSql('DROP INDEX folder_parent_position ON folders'); + $this->addSql('DROP INDEX log_ns_datetime ON log'); + $this->addSql('DROP INDEX log_username_datetime ON log'); + $this->addSql('DROP INDEX log_user_datetime ON log'); + $this->addSql('DROP INDEX log_level_datetime ON log'); + $this->addSql('DROP INDEX log_channel_datetime ON log'); + $this->addSql('DROP INDEX ns_node_translation_discr ON nodes_sources'); + $this->addSql('DROP INDEX tag_parent_position ON tags'); + } + } + + public function isTransactional(): bool + { + return false; + } +} diff --git a/lib/RoadizCoreBundle/migrations/Version20210423164248.php b/lib/RoadizCoreBundle/migrations/Version20210423164248.php new file mode 100644 index 00000000..74af5a4a --- /dev/null +++ b/lib/RoadizCoreBundle/migrations/Version20210423164248.php @@ -0,0 +1,129 @@ +addSql('CREATE INDEX answer_customform_submitted_at ON custom_form_answers (custom_form_id, submitted_at)'); + $this->addSql('CREATE INDEX cffattribute_answer_field ON custom_form_field_attributes (custom_form_answer_id, custom_form_field_id)'); + $this->addSql('CREATE INDEX cfield_customform_position ON custom_form_fields (custom_form_id, position)'); + $this->addSql('CREATE INDEX ntf_type_position ON node_type_fields (node_type_id, position)'); + $this->addSql('CREATE INDEX customform_node_position ON nodes_custom_forms (node_id, position)'); + $this->addSql('CREATE INDEX customform_node_field_position ON nodes_custom_forms (node_id, node_type_field_id, position)'); + + if ($this->connection->getDatabasePlatform()->getName() === 'postgresql') { + $this->addSql('DROP INDEX IF EXISTS IDX_7C7DED6DE0D4FDE19CAA2B25'); + $this->addSql('DROP INDEX IF EXISTS IDX_7C7DED6DE0D4FDE14AD260649CAA2B25'); + $this->addSql('DROP INDEX IF EXISTS IDX_7C7DED6D2B36786BE0D4FDE19CAA2B25'); + } else { + $this->addSql('DROP INDEX IDX_7C7DED6DE0D4FDE19CAA2B25 ON nodes_sources'); + $this->addSql('DROP INDEX IDX_7C7DED6DE0D4FDE14AD260649CAA2B25 ON nodes_sources'); + $this->addSql('DROP INDEX IDX_7C7DED6D2B36786BE0D4FDE19CAA2B25 ON nodes_sources'); + } + + $this->addSql('CREATE INDEX ns_node_discr_translation_published ON nodes_sources (node_id, discr, translation_id, published_at)'); + $this->addSql('CREATE INDEX ns_translation_published ON nodes_sources (translation_id, published_at)'); + $this->addSql('CREATE INDEX ns_discr_translation_published ON nodes_sources (discr, translation_id, published_at)'); + $this->addSql('CREATE INDEX ns_title_translation_published ON nodes_sources (title, translation_id, published_at)'); + + if ($this->connection->getDatabasePlatform()->getName() === 'postgresql') { + $this->addSql('ALTER INDEX IF EXISTS ns_node_translation_discr RENAME TO ns_node_discr_translation'); + $this->addSql('ALTER INDEX IF EXISTS idx_7c7ded6d460d9fd79caa2b25e0d4fde1 RENAME TO ns_node_translation_published'); + $this->addSql('ALTER INDEX IF EXISTS idx_7c7ded6d4ad260649caa2b25 RENAME TO ns_discr_translation'); + $this->addSql('ALTER INDEX IF EXISTS idx_7c7ded6d2b36786be0d4fde1 RENAME TO ns_title_published'); + $this->addSql('ALTER INDEX IF EXISTS idx_1cd104f7aa2d6147705282 RENAME TO nsdoc_field'); + $this->addSql('ALTER INDEX IF EXISTS idx_1cd104f7aa2d6147705282462ce4f5 RENAME TO nsdoc_field_position'); + $this->addSql('ALTER INDEX IF EXISTS idx_761f9a91fc7adece47705282 RENAME TO node_a_field'); + $this->addSql('ALTER INDEX IF EXISTS idx_761f9a91fc7adece47705282462ce4f5 RENAME TO node_a_field_position'); + $this->addSql('ALTER INDEX IF EXISTS idx_761f9a91eecf712047705282 RENAME TO node_b_field'); + $this->addSql('ALTER INDEX IF EXISTS idx_761f9a91eecf712047705282462ce4f5 RENAME TO node_b_field_position'); + $this->addSql('ALTER INDEX IF EXISTS idx_6e886f1f22010f1462ce4f5 RENAME TO tagtranslation_position'); + } else { + $this->addSql('ALTER TABLE nodes_sources RENAME INDEX ns_node_translation_discr TO ns_node_discr_translation'); + $this->addSql('ALTER TABLE nodes_sources RENAME INDEX idx_7c7ded6d460d9fd79caa2b25e0d4fde1 TO ns_node_translation_published'); + $this->addSql('ALTER TABLE nodes_sources RENAME INDEX idx_7c7ded6d4ad260649caa2b25 TO ns_discr_translation'); + $this->addSql('ALTER TABLE nodes_sources RENAME INDEX idx_7c7ded6d2b36786be0d4fde1 TO ns_title_published'); + $this->addSql('ALTER TABLE nodes_sources_documents RENAME INDEX idx_1cd104f7aa2d6147705282 TO nsdoc_field'); + $this->addSql('ALTER TABLE nodes_sources_documents RENAME INDEX idx_1cd104f7aa2d6147705282462ce4f5 TO nsdoc_field_position'); + $this->addSql('ALTER TABLE nodes_to_nodes RENAME INDEX idx_761f9a91fc7adece47705282 TO node_a_field'); + $this->addSql('ALTER TABLE nodes_to_nodes RENAME INDEX idx_761f9a91fc7adece47705282462ce4f5 TO node_a_field_position'); + $this->addSql('ALTER TABLE nodes_to_nodes RENAME INDEX idx_761f9a91eecf712047705282 TO node_b_field'); + $this->addSql('ALTER TABLE nodes_to_nodes RENAME INDEX idx_761f9a91eecf712047705282462ce4f5 TO node_b_field_position'); + $this->addSql('ALTER TABLE tags_translations_documents RENAME INDEX idx_6e886f1f22010f1462ce4f5 TO tagtranslation_position'); + } + } + + public function down(Schema $schema) : void + { + if ($this->connection->getDatabasePlatform()->getName() === 'postgresql') { + $this->addSql('DROP INDEX IF EXISTS answer_customform_submitted_at'); + $this->addSql('DROP INDEX IF EXISTS cffattribute_answer_field'); + $this->addSql('DROP INDEX IF EXISTS cfield_customform_positio'); + $this->addSql('DROP INDEX IF EXISTS ntf_type_position'); + $this->addSql('DROP INDEX IF EXISTS customform_node_position'); + $this->addSql('DROP INDEX IF EXISTS customform_node_field_position'); + $this->addSql('DROP INDEX IF EXISTS ns_node_discr_translation_published'); + $this->addSql('DROP INDEX IF EXISTS ns_translation_published'); + $this->addSql('DROP INDEX IF EXISTS ns_discr_translation_published'); + $this->addSql('DROP INDEX IF EXISTS ns_title_translation_published'); + } else { + $this->addSql('DROP INDEX answer_customform_submitted_at ON custom_form_answers'); + $this->addSql('DROP INDEX cffattribute_answer_field ON custom_form_field_attributes'); + $this->addSql('DROP INDEX cfield_customform_position ON custom_form_fields'); + $this->addSql('DROP INDEX ntf_type_position ON node_type_fields'); + $this->addSql('DROP INDEX customform_node_position ON nodes_custom_forms'); + $this->addSql('DROP INDEX customform_node_field_position ON nodes_custom_forms'); + $this->addSql('DROP INDEX ns_node_discr_translation_published ON nodes_sources'); + $this->addSql('DROP INDEX ns_translation_published ON nodes_sources'); + $this->addSql('DROP INDEX ns_discr_translation_published ON nodes_sources'); + $this->addSql('DROP INDEX ns_title_translation_published ON nodes_sources'); + } + + $this->addSql('CREATE INDEX IDX_7C7DED6DE0D4FDE19CAA2B25 ON nodes_sources (published_at, translation_id)'); + $this->addSql('CREATE INDEX IDX_7C7DED6DE0D4FDE14AD260649CAA2B25 ON nodes_sources (published_at, discr, translation_id)'); + $this->addSql('CREATE INDEX IDX_7C7DED6D2B36786BE0D4FDE19CAA2B25 ON nodes_sources (title, published_at, translation_id)'); + + if ($this->connection->getDatabasePlatform()->getName() === 'postgresql') { + $this->addSql('ALTER INDEX IF EXISTS ns_title_published RENAME TO IDX_7C7DED6D2B36786BE0D4FDE1'); + $this->addSql('ALTER INDEX IF EXISTS ns_node_discr_translation RENAME TO ns_node_translation_discr'); + $this->addSql('ALTER INDEX IF EXISTS ns_discr_translation RENAME TO IDX_7C7DED6D4AD260649CAA2B25'); + $this->addSql('ALTER INDEX IF EXISTS ns_node_translation_published RENAME TO IDX_7C7DED6D460D9FD79CAA2B25E0D4FDE1'); + $this->addSql('ALTER INDEX IF EXISTS nsdoc_field RENAME TO IDX_1CD104F7AA2D6147705282'); + $this->addSql('ALTER INDEX IF EXISTS nsdoc_field_position RENAME TO IDX_1CD104F7AA2D6147705282462CE4F5'); + $this->addSql('ALTER INDEX IF EXISTS node_b_field RENAME TO IDX_761F9A91EECF712047705282'); + $this->addSql('ALTER INDEX IF EXISTS node_b_field_position RENAME TO IDX_761F9A91EECF712047705282462CE4F5'); + $this->addSql('ALTER INDEX IF EXISTS node_a_field RENAME TO IDX_761F9A91FC7ADECE47705282'); + $this->addSql('ALTER INDEX IF EXISTS node_a_field_position RENAME TO IDX_761F9A91FC7ADECE47705282462CE4F5'); + $this->addSql('ALTER INDEX IF EXISTS tagtranslation_position RENAME TO IDX_6E886F1F22010F1462CE4F5'); + } else { + $this->addSql('ALTER TABLE nodes_sources RENAME INDEX ns_title_published TO IDX_7C7DED6D2B36786BE0D4FDE1'); + $this->addSql('ALTER TABLE nodes_sources RENAME INDEX ns_node_discr_translation TO ns_node_translation_discr'); + $this->addSql('ALTER TABLE nodes_sources RENAME INDEX ns_discr_translation TO IDX_7C7DED6D4AD260649CAA2B25'); + $this->addSql('ALTER TABLE nodes_sources RENAME INDEX ns_node_translation_published TO IDX_7C7DED6D460D9FD79CAA2B25E0D4FDE1'); + $this->addSql('ALTER TABLE nodes_sources_documents RENAME INDEX nsdoc_field TO IDX_1CD104F7AA2D6147705282'); + $this->addSql('ALTER TABLE nodes_sources_documents RENAME INDEX nsdoc_field_position TO IDX_1CD104F7AA2D6147705282462CE4F5'); + $this->addSql('ALTER TABLE nodes_to_nodes RENAME INDEX node_b_field TO IDX_761F9A91EECF712047705282'); + $this->addSql('ALTER TABLE nodes_to_nodes RENAME INDEX node_b_field_position TO IDX_761F9A91EECF712047705282462CE4F5'); + $this->addSql('ALTER TABLE nodes_to_nodes RENAME INDEX node_a_field TO IDX_761F9A91FC7ADECE47705282'); + $this->addSql('ALTER TABLE nodes_to_nodes RENAME INDEX node_a_field_position TO IDX_761F9A91FC7ADECE47705282462CE4F5'); + $this->addSql('ALTER TABLE tags_translations_documents RENAME INDEX tagtranslation_position TO IDX_6E886F1F22010F1462CE4F5'); + } + } + + public function isTransactional(): bool + { + return false; + } +} diff --git a/lib/RoadizCoreBundle/migrations/Version20210506085247.php b/lib/RoadizCoreBundle/migrations/Version20210506085247.php new file mode 100644 index 00000000..d1801bc4 --- /dev/null +++ b/lib/RoadizCoreBundle/migrations/Version20210506085247.php @@ -0,0 +1,77 @@ +addSql('CREATE INDEX node_status_parent ON nodes (status, parent_node_id)'); + $this->addSql('CREATE INDEX node_nodetype_status_parent ON nodes (nodeType_id, status, parent_node_id)'); + $this->addSql('CREATE INDEX node_nodetype_status_parent_position ON nodes (nodeType_id, status, parent_node_id, position)'); + $this->addSql('CREATE INDEX node_visible_parent_position ON nodes (visible, parent_node_id, position)'); + $this->addSql('CREATE INDEX node_status_visible_parent_position ON nodes (status, visible, parent_node_id, position)'); + + if ($this->connection->getDatabasePlatform()->getName() === 'postgresql') { + $this->addSql('ALTER INDEX IF EXISTS idx_1d3d05fc7ab0e8597b00651c3445eb91 RENAME TO node_visible_status_parent'); + $this->addSql('ALTER INDEX IF EXISTS idx_1d3d05fc7ab0e8593445eb91 RENAME TO node_visible_parent'); + } else { + $this->addSql('ALTER TABLE nodes RENAME INDEX idx_1d3d05fc7ab0e8597b00651c3445eb91 TO node_visible_status_parent'); + $this->addSql('ALTER TABLE nodes RENAME INDEX idx_1d3d05fc7ab0e8593445eb91 TO node_visible_parent'); + } + + $this->addSql('CREATE INDEX tag_visible_position ON tags (visible, position)'); + $this->addSql('CREATE INDEX tag_parent_visible_position ON tags (parent_tag_id, visible, position)'); + + if ($this->connection->getDatabasePlatform()->getName() === 'postgresql') { + $this->addSql('ALTER INDEX IF EXISTS idx_6fbc9426f5c1a0d77ab0e859 RENAME TO tag_parent_visible'); + } else { + $this->addSql('ALTER TABLE tags RENAME INDEX idx_6fbc9426f5c1a0d77ab0e859 TO tag_parent_visible'); + } + } + + public function down(Schema $schema): void + { + if ($this->connection->getDatabasePlatform()->getName() === 'postgresql') { + $this->addSql('DROP INDEX node_status_parent'); + $this->addSql('DROP INDEX node_nodetype_status_parent'); + $this->addSql('DROP INDEX node_nodetype_status_parent_position'); + $this->addSql('DROP INDEX node_visible_parent_position'); + $this->addSql('DROP INDEX node_status_visible_parent_position'); + $this->addSql('ALTER INDEX IF EXISTS node_visible_status_parent RENAME TO IDX_1D3D05FC7AB0E8597B00651C3445EB91'); + $this->addSql('ALTER INDEX IF EXISTS node_visible_parent RENAME TO IDX_1D3D05FC7AB0E8593445EB91'); + $this->addSql('DROP INDEX tag_visible_position'); + $this->addSql('DROP INDEX tag_parent_visible_position'); + $this->addSql('ALTER INDEX IF EXISTS tag_parent_visible RENAME TO IDX_6FBC9426F5C1A0D77AB0E859'); + } else { + $this->addSql('DROP INDEX node_status_parent ON nodes'); + $this->addSql('DROP INDEX node_nodetype_status_parent ON nodes'); + $this->addSql('DROP INDEX node_nodetype_status_parent_position ON nodes'); + $this->addSql('DROP INDEX node_visible_parent_position ON nodes'); + $this->addSql('DROP INDEX node_status_visible_parent_position ON nodes'); + $this->addSql('ALTER TABLE nodes RENAME INDEX node_visible_status_parent TO IDX_1D3D05FC7AB0E8597B00651C3445EB91'); + $this->addSql('ALTER TABLE nodes RENAME INDEX node_visible_parent TO IDX_1D3D05FC7AB0E8593445EB91'); + $this->addSql('DROP INDEX tag_visible_position ON tags'); + $this->addSql('DROP INDEX tag_parent_visible_position ON tags'); + $this->addSql('ALTER TABLE tags RENAME INDEX tag_parent_visible TO IDX_6FBC9426F5C1A0D77AB0E859'); + } + } + + public function isTransactional(): bool + { + return false; + } +} diff --git a/lib/RoadizCoreBundle/migrations/Version20210520092543.php b/lib/RoadizCoreBundle/migrations/Version20210520092543.php new file mode 100644 index 00000000..d20d9cc3 --- /dev/null +++ b/lib/RoadizCoreBundle/migrations/Version20210520092543.php @@ -0,0 +1,67 @@ +connection->getDatabasePlatform()->getName() === 'mysql') { + /* + * MYSQL + */ + $this->addSql('ALTER TABLE node_type_fields ADD serialization_exclusion_expression LONGTEXT DEFAULT NULL, ADD serialization_groups JSON DEFAULT NULL, ADD serialization_max_depth INT DEFAULT NULL, ADD excluded_from_serialization TINYINT(1) DEFAULT \'0\' NOT NULL'); + $this->addSql('ALTER TABLE node_types ADD searchable TINYINT(1) DEFAULT \'1\' NOT NULL'); + $this->addSql('CREATE INDEX nt_searchable ON node_types (searchable)'); + } elseif ($this->connection->getDatabasePlatform()->getName() === 'postgresql') { + /* + * POSTGRES + */ + $this->addSql('ALTER TABLE node_type_fields ADD serialization_exclusion_expression TEXT DEFAULT NULL'); + $this->addSql('ALTER TABLE node_type_fields ADD serialization_groups JSON DEFAULT NULL'); + $this->addSql('ALTER TABLE node_type_fields ADD serialization_max_depth INT DEFAULT NULL'); + $this->addSql('ALTER TABLE node_type_fields ADD excluded_from_serialization BOOLEAN DEFAULT \'false\' NOT NULL'); + + $this->addSql('ALTER TABLE node_types ADD searchable BOOLEAN DEFAULT \'true\' NOT NULL'); + $this->addSql('CREATE INDEX nt_searchable ON node_types (searchable)'); + } + } + + public function down(Schema $schema): void + { + if ($this->connection->getDatabasePlatform()->getName() === 'mysql') { + $this->addSql('ALTER TABLE node_type_fields DROP serialization_exclusion_expression, DROP serialization_groups, DROP serialization_max_depth, DROP excluded_from_serialization'); + $this->addSql('DROP INDEX nt_searchable ON node_types'); + $this->addSql('ALTER TABLE node_types DROP searchable'); + } elseif ($this->connection->getDatabasePlatform()->getName() === 'postgresql') { + /* + * POSTGRES + */ + $this->addSql('DROP INDEX nt_searchable'); + $this->addSql('ALTER TABLE node_types DROP searchable'); + + $this->addSql('ALTER TABLE node_type_fields DROP serialization_exclusion_expression'); + $this->addSql('ALTER TABLE node_type_fields DROP serialization_groups'); + $this->addSql('ALTER TABLE node_type_fields DROP serialization_max_depth'); + $this->addSql('ALTER TABLE node_type_fields DROP excluded_from_serialization'); + } + } + + public function isTransactional(): bool + { + return false; + } +} diff --git a/lib/RoadizCoreBundle/migrations/Version20210527131435.php b/lib/RoadizCoreBundle/migrations/Version20210527131435.php new file mode 100644 index 00000000..44edb4ff --- /dev/null +++ b/lib/RoadizCoreBundle/migrations/Version20210527131435.php @@ -0,0 +1,43 @@ +connection->getDatabasePlatform()->getName() === 'postgresql') { + $this->addSql('ALTER TABLE redirections ALTER redirecturi TYPE TEXT'); + $this->addSql('ALTER TABLE redirections ALTER redirecturi DROP DEFAULT'); + $this->addSql('ALTER TABLE redirections ALTER redirecturi TYPE TEXT'); + } else { + $this->addSql('ALTER TABLE redirections CHANGE redirectUri redirectUri TEXT DEFAULT NULL'); + } + } + + public function down(Schema $schema): void + { + if ($this->connection->getDatabasePlatform()->getName() === 'postgresql') { + $this->addSql('ALTER TABLE redirections ALTER redirecturi TYPE VARCHAR(255)'); + $this->addSql('ALTER TABLE redirections ALTER redirecturi DROP DEFAULT'); + $this->addSql('ALTER TABLE redirections ALTER redirecturi TYPE VARCHAR(255)'); + } else { + $this->addSql('ALTER TABLE redirections CHANGE redirectUri redirectUri VARCHAR(255) DEFAULT NULL'); + } + } + + public function isTransactional(): bool + { + return false; + } +} diff --git a/lib/RoadizCoreBundle/migrations/Version20210701151713.php b/lib/RoadizCoreBundle/migrations/Version20210701151713.php new file mode 100644 index 00000000..00a6a06e --- /dev/null +++ b/lib/RoadizCoreBundle/migrations/Version20210701151713.php @@ -0,0 +1,42 @@ +connection->getDatabasePlatform()->getName() === 'postgresql') { + $this->addSql('CREATE TABLE webhooks (id VARCHAR(36) NOT NULL, message_type VARCHAR(255) DEFAULT NULL, uri TEXT DEFAULT NULL, payload JSON DEFAULT NULL, throttleSeconds INT NOT NULL, last_triggered_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, automatic BOOLEAN DEFAULT \'false\' NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX webhook_message_type ON webhooks (message_type)'); + $this->addSql('CREATE INDEX webhook_created_at ON webhooks (created_at)'); + $this->addSql('CREATE INDEX webhook_updated_at ON webhooks (updated_at)'); + $this->addSql('CREATE INDEX webhook_last_triggered_at ON webhooks (last_triggered_at)'); + $this->addSql('CREATE INDEX IDX_998C4FDD8B8E8428 ON webhooks (created_at)'); + $this->addSql('CREATE INDEX IDX_998C4FDD43625D9F ON webhooks (updated_at)'); + $this->addSql('CREATE INDEX webhook_automatic ON webhooks (automatic)'); + } else { + $this->addSql('CREATE TABLE webhooks (id VARCHAR(36) NOT NULL, message_type VARCHAR(255) DEFAULT NULL, uri LONGTEXT DEFAULT NULL, payload JSON DEFAULT NULL, throttleSeconds INT NOT NULL, last_triggered_at DATETIME DEFAULT NULL, automatic TINYINT(1) DEFAULT \'0\' NOT NULL, created_at DATETIME DEFAULT NULL, updated_at DATETIME DEFAULT NULL, INDEX webhook_message_type (message_type), INDEX webhook_created_at (created_at), INDEX webhook_updated_at (updated_at), INDEX webhook_last_triggered_at (last_triggered_at), INDEX webhook_automatic (automatic), INDEX IDX_998C4FDD8B8E8428 (created_at), INDEX IDX_998C4FDD43625D9F (updated_at), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8 COLLATE `utf8_unicode_ci` ENGINE = InnoDB'); + } + } + + public function down(Schema $schema): void + { + $this->addSql('DROP TABLE webhooks'); + } + + public function isTransactional(): bool + { + return false; + } +} diff --git a/lib/RoadizCoreBundle/migrations/Version20210715120118.php b/lib/RoadizCoreBundle/migrations/Version20210715120118.php new file mode 100644 index 00000000..9e28c5b6 --- /dev/null +++ b/lib/RoadizCoreBundle/migrations/Version20210715120118.php @@ -0,0 +1,48 @@ +connection->getDatabasePlatform()->getName() === 'postgresql') { + $this->addSql('ALTER TABLE webhooks ADD root_node INT DEFAULT NULL'); + $this->addSql('ALTER TABLE webhooks ADD CONSTRAINT FK_998C4FDDC2A25172 FOREIGN KEY (root_node) REFERENCES nodes (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE'); + } else { + $this->addSql('ALTER TABLE webhooks ADD root_node INT DEFAULT NULL'); + $this->addSql('ALTER TABLE webhooks ADD CONSTRAINT FK_998C4FDDC2A25172 FOREIGN KEY (root_node) REFERENCES nodes (id) ON DELETE SET NULL'); + } + $this->addSql('CREATE INDEX webhook_root_node ON webhooks (root_node)'); + } + + public function down(Schema $schema): void + { + if ($this->connection->getDatabasePlatform()->getName() === 'postgresql') { + $this->addSql('ALTER TABLE webhooks DROP CONSTRAINT FK_998C4FDDC2A25172'); + $this->addSql('DROP INDEX webhook_root_node'); + } else { + $this->addSql('ALTER TABLE webhooks DROP FOREIGN KEY FK_998C4FDDC2A25172'); + $this->addSql('DROP INDEX webhook_root_node ON webhooks'); + } + $this->addSql('ALTER TABLE webhooks DROP root_node'); + } + + public function isTransactional(): bool + { + return false; + } +} diff --git a/lib/RoadizCoreBundle/migrations/Version20210802132310.php b/lib/RoadizCoreBundle/migrations/Version20210802132310.php new file mode 100644 index 00000000..01ccc691 --- /dev/null +++ b/lib/RoadizCoreBundle/migrations/Version20210802132310.php @@ -0,0 +1,34 @@ +addSql('ALTER TABLE webhooks ADD description LONGTEXT DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE webhooks DROP description'); + } + + public function isTransactional(): bool + { + return false; + } +} diff --git a/lib/RoadizCoreBundle/migrations/Version20220104132107.php b/lib/RoadizCoreBundle/migrations/Version20220104132107.php new file mode 100644 index 00000000..675c5f01 --- /dev/null +++ b/lib/RoadizCoreBundle/migrations/Version20220104132107.php @@ -0,0 +1,36 @@ +addSql('CREATE INDEX node_type_name ON node_types (name)'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('DROP INDEX node_type_name ON node_types'); + } + + public function isTransactional(): bool + { + return false; + } +} diff --git a/lib/RoadizCoreBundle/migrations/Version20220204180955.php b/lib/RoadizCoreBundle/migrations/Version20220204180955.php new file mode 100644 index 00000000..6654ed37 --- /dev/null +++ b/lib/RoadizCoreBundle/migrations/Version20220204180955.php @@ -0,0 +1,50 @@ +addSql('CREATE INDEX document_filename ON documents (filename)'); + $this->addSql('CREATE INDEX document_embed_id ON documents (embedId)'); + $this->addSql('CREATE INDEX document_embed_platform_id ON documents (embedId, embedPlatform)'); + $this->addSql('CREATE INDEX document_duration ON documents (duration)'); + $this->addSql('CREATE INDEX document_filesize ON documents (filesize)'); + $this->addSql('CREATE INDEX document_image_width ON documents (imageWidth)'); + $this->addSql('CREATE INDEX document_image_height ON documents (imageHeight)'); + $this->addSql('ALTER TABLE documents_translations ADD external_url TEXT DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('DROP INDEX document_filename ON documents'); + $this->addSql('DROP INDEX document_embed_id ON documents'); + $this->addSql('DROP INDEX document_embed_platform_id ON documents'); + $this->addSql('DROP INDEX document_duration ON documents'); + $this->addSql('DROP INDEX document_filesize ON documents'); + $this->addSql('DROP INDEX document_image_width ON documents'); + $this->addSql('DROP INDEX document_image_height ON documents'); + $this->addSql('ALTER TABLE documents_translations DROP external_url'); + } + + public function isTransactional(): bool + { + return false; + } +} diff --git a/lib/RoadizCoreBundle/migrations/Version20220525131842.php b/lib/RoadizCoreBundle/migrations/Version20220525131842.php new file mode 100644 index 00000000..86afe87d --- /dev/null +++ b/lib/RoadizCoreBundle/migrations/Version20220525131842.php @@ -0,0 +1,47 @@ +addSql('CREATE TABLE realms (id INT AUTO_INCREMENT NOT NULL, role_id INT DEFAULT NULL, type VARCHAR(30) NOT NULL, name VARCHAR(255) NOT NULL, plain_password VARCHAR(255) DEFAULT NULL, serialization_group VARCHAR(200) DEFAULT NULL, UNIQUE INDEX UNIQ_7DF2621A5E237E06 (name), INDEX IDX_7DF2621AD60322AC (role_id), INDEX realms_type (type), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('CREATE TABLE realms_users (realm_id INT NOT NULL, user_id INT NOT NULL, INDEX IDX_AF42698A9DFF5F89 (realm_id), INDEX IDX_AF42698AA76ED395 (user_id), PRIMARY KEY(realm_id, user_id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('CREATE TABLE realms_nodes (id INT AUTO_INCREMENT NOT NULL, node_id INT NOT NULL, realm_id INT DEFAULT NULL, inheritance_type VARCHAR(10) NOT NULL, INDEX realms_nodes_inheritance_type (inheritance_type), INDEX realms_nodes_realm (realm_id), INDEX realms_nodes_node (node_id), UNIQUE INDEX realms_nodes_unique (node_id, realm_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('ALTER TABLE realms ADD CONSTRAINT FK_7DF2621AD60322AC FOREIGN KEY (role_id) REFERENCES roles (id) ON DELETE SET NULL'); + $this->addSql('ALTER TABLE realms_users ADD CONSTRAINT FK_AF42698A9DFF5F89 FOREIGN KEY (realm_id) REFERENCES realms (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE realms_users ADD CONSTRAINT FK_AF42698AA76ED395 FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE realms_nodes ADD CONSTRAINT FK_A6FCC99F460D9FD7 FOREIGN KEY (node_id) REFERENCES nodes (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE realms_nodes ADD CONSTRAINT FK_A6FCC99F9DFF5F89 FOREIGN KEY (realm_id) REFERENCES realms (id) ON DELETE SET NULL'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE realms_users DROP FOREIGN KEY FK_AF42698A9DFF5F89'); + $this->addSql('ALTER TABLE realms_nodes DROP FOREIGN KEY FK_A6FCC99F9DFF5F89'); + $this->addSql('DROP TABLE realms'); + $this->addSql('DROP TABLE realms_users'); + $this->addSql('DROP TABLE realms_nodes'); + } + + public function isTransactional(): bool + { + return false; + } +} diff --git a/lib/RoadizCoreBundle/migrations/Version20220525150545.php b/lib/RoadizCoreBundle/migrations/Version20220525150545.php new file mode 100644 index 00000000..0f23dbee --- /dev/null +++ b/lib/RoadizCoreBundle/migrations/Version20220525150545.php @@ -0,0 +1,38 @@ +addSql('ALTER TABLE realms_nodes DROP FOREIGN KEY FK_A6FCC99F9DFF5F89'); + $this->addSql('ALTER TABLE realms_nodes ADD CONSTRAINT FK_A6FCC99F9DFF5F89 FOREIGN KEY (realm_id) REFERENCES realms (id) ON DELETE CASCADE'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE realms_nodes DROP FOREIGN KEY FK_A6FCC99F9DFF5F89'); + $this->addSql('ALTER TABLE realms_nodes ADD CONSTRAINT FK_A6FCC99F9DFF5F89 FOREIGN KEY (realm_id) REFERENCES realms (id) ON UPDATE NO ACTION ON DELETE SET NULL'); + } + + public function isTransactional(): bool + { + return false; + } +} diff --git a/lib/RoadizCoreBundle/migrations/Version20220530112008.php b/lib/RoadizCoreBundle/migrations/Version20220530112008.php new file mode 100644 index 00000000..b72c60be --- /dev/null +++ b/lib/RoadizCoreBundle/migrations/Version20220530112008.php @@ -0,0 +1,36 @@ +addSql('CREATE INDEX realms_nodes_node_inheritance_type ON realms_nodes (node_id, inheritance_type)'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('DROP INDEX realms_nodes_node_inheritance_type ON realms_nodes'); + } + + public function isTransactional(): bool + { + return false; + } +} diff --git a/lib/RoadizCoreBundle/migrations/Version20220530132117.php b/lib/RoadizCoreBundle/migrations/Version20220530132117.php new file mode 100644 index 00000000..486b023c --- /dev/null +++ b/lib/RoadizCoreBundle/migrations/Version20220530132117.php @@ -0,0 +1,38 @@ +addSql('ALTER TABLE realms ADD behaviour VARCHAR(30) DEFAULT \'none\' NOT NULL'); + $this->addSql('CREATE INDEX realms_behaviour ON realms (behaviour)'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('DROP INDEX realms_behaviour ON realms'); + $this->addSql('ALTER TABLE realms DROP behaviour'); + } + + public function isTransactional(): bool + { + return false; + } +} diff --git a/lib/RoadizCoreBundle/migrations/Version20220602173719.php b/lib/RoadizCoreBundle/migrations/Version20220602173719.php new file mode 100644 index 00000000..53268796 --- /dev/null +++ b/lib/RoadizCoreBundle/migrations/Version20220602173719.php @@ -0,0 +1,40 @@ +addSql('ALTER TABLE documents ADD file_hash VARCHAR(64) DEFAULT NULL, ADD file_hash_algorithm VARCHAR(15) DEFAULT NULL'); + $this->addSql('CREATE INDEX document_file_hash ON documents (file_hash)'); + $this->addSql('CREATE INDEX document_hash_algorithm ON documents (file_hash_algorithm)'); + $this->addSql('CREATE INDEX document_file_hash_algorithm ON documents (file_hash, file_hash_algorithm)'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP INDEX document_file_hash ON documents'); + $this->addSql('DROP INDEX document_hash_algorithm ON documents'); + $this->addSql('DROP INDEX document_file_hash_algorithm ON documents'); + $this->addSql('ALTER TABLE documents DROP file_hash, DROP file_hash_algorithm'); + } + + public function isTransactional(): bool + { + return false; + } +} diff --git a/lib/RoadizCoreBundle/migrations/Version20220622141902.php b/lib/RoadizCoreBundle/migrations/Version20220622141902.php new file mode 100644 index 00000000..a3bbb8b6 --- /dev/null +++ b/lib/RoadizCoreBundle/migrations/Version20220622141902.php @@ -0,0 +1,36 @@ +addSql('ALTER TABLE custom_forms ADD retention_time VARCHAR(15) DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE custom_forms DROP retention_time'); + } + + public function isTransactional(): bool + { + return false; + } +} diff --git a/lib/RoadizCoreBundle/migrations/Version20220623133037.php b/lib/RoadizCoreBundle/migrations/Version20220623133037.php new file mode 100644 index 00000000..d1402a15 --- /dev/null +++ b/lib/RoadizCoreBundle/migrations/Version20220623133037.php @@ -0,0 +1,36 @@ +addSql('ALTER TABLE documents ADD copyright_valid_since DATETIME DEFAULT NULL, ADD copyright_valid_until DATETIME DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE documents DROP copyright_valid_since, DROP copyright_valid_until'); + } + + public function isTransactional(): bool + { + return false; + } +} diff --git a/lib/RoadizCoreBundle/migrations/Version20220729100037.php b/lib/RoadizCoreBundle/migrations/Version20220729100037.php new file mode 100644 index 00000000..8f72a64e --- /dev/null +++ b/lib/RoadizCoreBundle/migrations/Version20220729100037.php @@ -0,0 +1,36 @@ +addSql('ALTER TABLE documents DROP INDEX UNIQ_A2B0728826CBD5A5, ADD INDEX IDX_A2B0728826CBD5A5 (raw_document)'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE documents DROP INDEX IDX_A2B0728826CBD5A5, ADD UNIQUE INDEX UNIQ_A2B0728826CBD5A5 (raw_document)'); + } + + public function isTransactional(): bool + { + return false; + } +} diff --git a/lib/RoadizCoreBundle/migrations/Version20220901082425.php b/lib/RoadizCoreBundle/migrations/Version20220901082425.php new file mode 100644 index 00000000..c78c5216 --- /dev/null +++ b/lib/RoadizCoreBundle/migrations/Version20220901082425.php @@ -0,0 +1,44 @@ +addSql('ALTER TABLE folders ADD locked TINYINT(1) DEFAULT \'0\' NOT NULL, ADD color VARCHAR(7) DEFAULT \'#000000\' NOT NULL'); + $this->addSql('CREATE INDEX IDX_FE37D30FEAD2C891 ON folders (locked)'); + $this->addSql('CREATE INDEX folder_visible_position ON folders (visible, position)'); + $this->addSql('CREATE INDEX folder_parent_visible ON folders (parent_id, visible)'); + $this->addSql('CREATE INDEX folder_parent_visible_position ON folders (parent_id, visible, position)'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('DROP INDEX IDX_FE37D30FEAD2C891 ON folders'); + $this->addSql('DROP INDEX folder_visible_position ON folders'); + $this->addSql('DROP INDEX folder_parent_visible ON folders'); + $this->addSql('DROP INDEX folder_parent_visible_position ON folders'); + $this->addSql('ALTER TABLE folders DROP locked, DROP color'); + } + + public function isTransactional(): bool + { + return false; + } +} diff --git a/lib/RoadizCoreBundle/migrations/Version20221007085729.php b/lib/RoadizCoreBundle/migrations/Version20221007085729.php new file mode 100644 index 00000000..69bc588d --- /dev/null +++ b/lib/RoadizCoreBundle/migrations/Version20221007085729.php @@ -0,0 +1,36 @@ +addSql('ALTER TABLE users ADD publicName VARCHAR(255) DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE users DROP publicName'); + } + + public function isTransactional(): bool + { + return false; + } +} diff --git a/lib/RoadizCoreBundle/migrations/Version20221018195433.php b/lib/RoadizCoreBundle/migrations/Version20221018195433.php new file mode 100644 index 00000000..eb8153aa --- /dev/null +++ b/lib/RoadizCoreBundle/migrations/Version20221018195433.php @@ -0,0 +1,50 @@ +addSql('ALTER TABLE nodes_tags DROP PRIMARY KEY'); + $this->addSql('ALTER TABLE nodes_tags ADD position DOUBLE PRECISION DEFAULT \'1\' NOT NULL'); + $this->addSql('CREATE INDEX nodes_tags_node_id_position ON nodes_tags (node_id, position)'); + $this->addSql('CREATE INDEX nodes_tags_tag_id_position ON nodes_tags (tag_id, position)'); + $this->addSql('CREATE INDEX nodes_tags_position ON nodes_tags (position)'); + $this->addSql('ALTER TABLE nodes_tags ADD PRIMARY KEY (node_id, tag_id, position)'); + $this->addSql('ALTER TABLE nodes_tags RENAME INDEX idx_5b5cb38cbad26311 TO nodes_tags_tag_id'); + $this->addSql('ALTER TABLE nodes_tags RENAME INDEX idx_5b5cb38c460d9fd7 TO nodes_tags_node_id'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('DROP INDEX nodes_tags_node_id_position ON nodes_tags'); + $this->addSql('DROP INDEX nodes_tags_tag_id_position ON nodes_tags'); + $this->addSql('DROP INDEX nodes_tags_position ON nodes_tags'); + $this->addSql('ALTER TABLE nodes_tags DROP PRIMARY KEY'); + $this->addSql('ALTER TABLE nodes_tags DROP position'); + $this->addSql('ALTER TABLE nodes_tags ADD PRIMARY KEY (node_id, tag_id)'); + $this->addSql('ALTER TABLE nodes_tags RENAME INDEX nodes_tags_node_id TO IDX_5B5CB38C460D9FD7'); + $this->addSql('ALTER TABLE nodes_tags RENAME INDEX nodes_tags_tag_id TO IDX_5B5CB38CBAD26311'); + } + + public function isTransactional(): bool + { + return false; + } +} diff --git a/lib/RoadizCoreBundle/migrations/Version20221220181250.php b/lib/RoadizCoreBundle/migrations/Version20221220181250.php new file mode 100644 index 00000000..f67ee37b --- /dev/null +++ b/lib/RoadizCoreBundle/migrations/Version20221220181250.php @@ -0,0 +1,35 @@ +addSql('ALTER TABLE nodes_sources ADD no_index TINYINT(1) DEFAULT \'0\' NOT NULL'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE nodes_sources DROP no_index'); + } + + public function isTransactional(): bool + { + return false; + } +} diff --git a/lib/RoadizCoreBundle/migrations/Version20230125105107.php b/lib/RoadizCoreBundle/migrations/Version20230125105107.php new file mode 100644 index 00000000..cc9baca7 --- /dev/null +++ b/lib/RoadizCoreBundle/migrations/Version20230125105107.php @@ -0,0 +1,34 @@ +addSql('ALTER TABLE nodes_sources CHANGE discr discr VARCHAR(30) NOT NULL'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE nodes_sources CHANGE discr discr VARCHAR(255) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_ci`'); + } + + public function isTransactional(): bool + { + return false; + } +} diff --git a/lib/RoadizCoreBundle/phpcs.xml.dist b/lib/RoadizCoreBundle/phpcs.xml.dist new file mode 100644 index 00000000..19bff0cc --- /dev/null +++ b/lib/RoadizCoreBundle/phpcs.xml.dist @@ -0,0 +1,13 @@ + + + + + + + + + + + src/ + diff --git a/lib/RoadizCoreBundle/phpstan.neon b/lib/RoadizCoreBundle/phpstan.neon new file mode 100644 index 00000000..bedd1ca4 --- /dev/null +++ b/lib/RoadizCoreBundle/phpstan.neon @@ -0,0 +1,32 @@ +parameters: + level: 6 + paths: + - src + excludePaths: + - */node_modules/* + - */bower_components/* + - */static/* + ignoreErrors: + - '#Call to an undefined method RZ\\Roadiz\\CoreBundle\\Repository#' + - '#Call to an undefined method RZ\\Roadiz\\UserBundle\\Repository#' + - '#Call to an undefined method Doctrine\\Persistence\\ObjectRepository#' + - '#Call to an undefined method Doctrine\\Persistence\\ObjectManager#' + - '#Call to an undefined method Doctrine\\ORM\\EntityRepository#' + - '#Call to an undefined method Symfony\\Component\\Config\\Definition\\Builder\\NodeDefinition::#' + - '#Access to an undefined property Symfony\\Component\\Validator\\Constraint::#' + - '#RZ\\Roadiz\\Core\\Entities\\Tag does not have a field named \$translation#' + - '#does not have a field named \$node\.home#' + - '#does not have a field named \$node\.id#' + - '#does not have a field named \$node\.parent#' + - '#does not have a field named \$translation#' + # PHPStan Doctrine does not support ResolveTargetEntityListener + - '#Property ([a-zA-Z\\\:\$]+) type mapping mismatch: property can contain ([a-zA-Z\\\&\>\<]+)Interface\>?\|null but database expects ([a-zA-Z\\\&\>\<]+)\|null#' + - '#Property ([a-zA-Z\\\:\$]+) type mapping mismatch: property can contain ([a-zA-Z\\\&\>\<]+)Interface\>? but database expects ([a-zA-Z\\\&\>\<]+)#' + - '#type mapping mismatch: database can contain array\|bool\|float\|int\|JsonSerializable\|stdClass\|string\|null but property expects array\|null#' + - '#Doctrine\\ORM\\Mapping\\GeneratedValue constructor expects#' + + reportUnmatchedIgnoredErrors: false + checkGenericClassInNonGenericObjectType: false + checkMissingIterableValueType: false +includes: + - vendor/phpstan/phpstan-doctrine/extension.neon diff --git a/lib/RoadizCoreBundle/public/assets/.gitkeep b/lib/RoadizCoreBundle/public/assets/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/lib/RoadizCoreBundle/public/assets/roadiz_white.jpg b/lib/RoadizCoreBundle/public/assets/roadiz_white.jpg new file mode 100644 index 0000000000000000000000000000000000000000..64c8c753687eb78e9970f951f16e922f096b2b99 GIT binary patch literal 31306 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

G@7awbj%#iGmu-0w`LVWg1$xCFyHg+c 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 literal 0 HcmV?d00001 diff --git a/lib/RoadizCoreBundle/public/files/.gitkeep b/lib/RoadizCoreBundle/public/files/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/lib/RoadizCoreBundle/src/Api/Breadcrumbs/Breadcrumbs.php b/lib/RoadizCoreBundle/src/Api/Breadcrumbs/Breadcrumbs.php new file mode 100644 index 00000000..1105ff4a --- /dev/null +++ b/lib/RoadizCoreBundle/src/Api/Breadcrumbs/Breadcrumbs.php @@ -0,0 +1,34 @@ + + */ + #[Serializer\Groups(["breadcrumbs", "web_response"])] + #[Serializer\MaxDepth(1)] + private array $items; + + /** + * @param array $items + */ + public function __construct(array $items) + { + $this->items = $items; + } + + /** + * @return PersistableInterface[] + */ + public function getItems(): array + { + return $this->items; + } +} diff --git a/lib/RoadizCoreBundle/src/Api/Breadcrumbs/BreadcrumbsFactoryInterface.php b/lib/RoadizCoreBundle/src/Api/Breadcrumbs/BreadcrumbsFactoryInterface.php new file mode 100644 index 00000000..532ef4df --- /dev/null +++ b/lib/RoadizCoreBundle/src/Api/Breadcrumbs/BreadcrumbsFactoryInterface.php @@ -0,0 +1,12 @@ +getNode() || + null === $entity->getNode()->getNodeType() || + !$entity->isReachable() + ) { + return null; + } + $parents = []; + + while (null !== $entity = $entity->getParent()) { + if ( + null !== $entity->getNode() && + $entity->getNode()->isVisible() + ) { + $parents[] = $entity; + } + } + + return new Breadcrumbs(array_reverse($parents)); + } +} diff --git a/lib/RoadizCoreBundle/src/Api/Controller/GetWebResponseByPathController.php b/lib/RoadizCoreBundle/src/Api/Controller/GetWebResponseByPathController.php new file mode 100644 index 00000000..9b0a9b80 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Api/Controller/GetWebResponseByPathController.php @@ -0,0 +1,121 @@ +requestStack = $requestStack; + $this->pathResolver = $pathResolver; + $this->webResponseDataTransformer = $webResponseDataTransformer; + $this->iriConverter = $iriConverter; + } + + public function __invoke(): ?WebResponseInterface + { + try { + if ( + null === $this->requestStack->getMainRequest() || + empty($this->requestStack->getMainRequest()->query->get('path')) + ) { + throw new InvalidArgumentException('path query parameter is mandatory'); + } + $resource = $this->normalizeResourcePath( + (string) $this->requestStack->getMainRequest()->query->get('path') + ); + $this->requestStack->getMainRequest()->attributes->set('data', $resource); + /* + * Force API Platform to look for real resource configuration and serialization + * context. You must define "itemOperations.getByPath" for your API resource configuration. + */ + $this->requestStack->getMainRequest()->attributes->set('_api_resource_class', get_class($resource)); + return $this->webResponseDataTransformer->transform($resource, WebResponseInterface::class); + } catch (ResourceNotFoundException $exception) { + throw new NotFoundHttpException($exception->getMessage(), $exception); + } + } + + /** + * @param string $path + * @return PersistableInterface|null + */ + protected function normalizeResourcePath(string $path): ?PersistableInterface + { + /* + * Serve any PersistableInterface Resource by implementing + * your PathResolver and tagging it "roadiz_core.path_resolver" + */ + $resourceInfo = $this->pathResolver->resolvePath( + $path, + ['html', 'json'], + true, + false + ); + $resource = $resourceInfo->getResource(); + + /* + * Normalize redirection + */ + if ($resource instanceof Redirection) { + if (null !== $resource->getRedirectNodeSource()) { + $resource = $resource->getRedirectNodeSource(); + } elseif ( + null !== $resource->getRedirectUri() && + (new UnicodeString($resource->getRedirectUri()))->startsWith('/') + ) { + /* + * Recursive call to normalize path coming from Redirection if redirected path + * is internal (starting with /) + */ + return $this->normalizeResourcePath($resource->getRedirectUri()); + } + } + + $this->addResourceToCacheTags($resource); + + /* + * Or plain entity + */ + return $resource; + } + + protected function addResourceToCacheTags(?PersistableInterface $resource): void + { + $request = $this->requestStack->getMainRequest(); + if (null !== $request && null !== $resource) { + $iri = $this->iriConverter->getIriFromItem($resource); + $request->attributes->set('_resources', $request->attributes->get('_resources', []) + [$iri]); + } + } +} diff --git a/lib/RoadizCoreBundle/src/Api/Controller/NodesSourcesSearchController.php b/lib/RoadizCoreBundle/src/Api/Controller/NodesSourcesSearchController.php new file mode 100644 index 00000000..ad76db3d --- /dev/null +++ b/lib/RoadizCoreBundle/src/Api/Controller/NodesSourcesSearchController.php @@ -0,0 +1,82 @@ +nodeSourceSearchHandler = $nodeSourceSearchHandler; + $this->highlightingFragmentSize = $highlightingFragmentSize; + $this->managerRegistry = $managerRegistry; + $this->previewResolver = $previewResolver; + } + + protected function getManagerRegistry(): ManagerRegistry + { + return $this->managerRegistry; + } + + protected function getPreviewResolver(): PreviewResolverInterface + { + return $this->previewResolver; + } + + /** + * @return SearchHandlerInterface + */ + protected function getSearchHandler(): SearchHandlerInterface + { + if (null === $this->nodeSourceSearchHandler) { + throw new HttpException(Response::HTTP_SERVICE_UNAVAILABLE, 'Search engine does not respond.'); + } + $this->nodeSourceSearchHandler->boostByPublicationDate(); + if ($this->highlightingFragmentSize > 0) { + $this->nodeSourceSearchHandler->setHighlightingFragmentSize($this->highlightingFragmentSize); + } + return $this->nodeSourceSearchHandler; + } + + protected function getCriteria(Request $request): array + { + return [ + 'publishedAt' => ['<=', new \DateTime()], + 'translation' => $this->getTranslation($request) + ]; + } + + public function __invoke(Request $request): SolrPaginator + { + $entityListManager = new SolrSearchListManager( + $request, + $this->getSearchHandler(), + $this->getCriteria($request), + true + ); + return new SolrPaginator($entityListManager); + } +} diff --git a/lib/RoadizCoreBundle/src/Api/Controller/TranslationAwareControllerTrait.php b/lib/RoadizCoreBundle/src/Api/Controller/TranslationAwareControllerTrait.php new file mode 100644 index 00000000..f291948d --- /dev/null +++ b/lib/RoadizCoreBundle/src/Api/Controller/TranslationAwareControllerTrait.php @@ -0,0 +1,40 @@ +query->get('_locale'); + /** @var TranslationRepository $repository */ + $repository = $this->getManagerRegistry()->getRepository(TranslationInterface::class); + if (null === $locale) { + return $repository->findDefault(); + } + + if ($this->getPreviewResolver()->isPreview()) { + $translation = $repository->findOneByLocaleOrOverrideLocale($locale); + } else { + $translation = $repository->findOneAvailableByLocaleOrOverrideLocale($locale); + } + + if (null !== $translation) { + return $translation; + } + + throw new BadRequestHttpException(sprintf('“%s” locale is not available', $locale)); + } +} diff --git a/lib/RoadizCoreBundle/src/Api/DataTransformer/AttributeOutputDataTransformer.php b/lib/RoadizCoreBundle/src/Api/DataTransformer/AttributeOutputDataTransformer.php new file mode 100644 index 00000000..15bdea15 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Api/DataTransformer/AttributeOutputDataTransformer.php @@ -0,0 +1,45 @@ +group = $data->getGroup(); + $output->code = $data->getCode(); + $output->type = $data->getType(); + $output->color = $data->getColor(); + $output->documents = $data->getDocuments()->toArray(); + + if (isset($context['translation']) && $context['translation'] instanceof TranslationInterface) { + $output->name = $data->getLabelOrCode($context['translation']); + } + return $output; + } + + /** + * @inheritDoc + */ + public function supportsTransformation($data, string $to, array $context = []): bool + { + return AttributeOutput::class === $to && $data instanceof AttributeInterface; + } +} diff --git a/lib/RoadizCoreBundle/src/Api/DataTransformer/AttributeValueOutputDataTransformer.php b/lib/RoadizCoreBundle/src/Api/DataTransformer/AttributeValueOutputDataTransformer.php new file mode 100644 index 00000000..9421721b --- /dev/null +++ b/lib/RoadizCoreBundle/src/Api/DataTransformer/AttributeValueOutputDataTransformer.php @@ -0,0 +1,48 @@ +attribute = $data->getAttribute(); + $output->attributable = $data->getAttributable(); + $output->type = $data->getType(); + + if (isset($context['translation']) && $context['translation'] instanceof TranslationInterface) { + $translatedData = $data->getAttributeValueTranslation($context['translation']); + $output->label = $data->getAttribute()->getLabelOrCode($context['translation']); + if ($translatedData instanceof AttributeValueTranslationInterface) { + $output->value = $translatedData->getValue(); + } + } + return $output; + } + + /** + * @inheritDoc + */ + public function supportsTransformation($data, string $to, array $context = []): bool + { + return AttributeValueOutput::class === $to && $data instanceof AttributeValueInterface; + } +} diff --git a/lib/RoadizCoreBundle/src/Api/DataTransformer/BaseNodesSourcesOutputDataTransformer.php b/lib/RoadizCoreBundle/src/Api/DataTransformer/BaseNodesSourcesOutputDataTransformer.php new file mode 100644 index 00000000..2465864f --- /dev/null +++ b/lib/RoadizCoreBundle/src/Api/DataTransformer/BaseNodesSourcesOutputDataTransformer.php @@ -0,0 +1,35 @@ +transformNodesSources($output, $data, $context); + } + + /** + * @inheritDoc + */ + public function supportsTransformation($data, string $to, array $context = []): bool + { + return NodesSourcesOutput::class === $to && $data instanceof NodesSources; + } +} diff --git a/lib/RoadizCoreBundle/src/Api/DataTransformer/BlocksAwareWebResponseOutputDataTransformerTrait.php b/lib/RoadizCoreBundle/src/Api/DataTransformer/BlocksAwareWebResponseOutputDataTransformerTrait.php new file mode 100644 index 00000000..b5be9688 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Api/DataTransformer/BlocksAwareWebResponseOutputDataTransformerTrait.php @@ -0,0 +1,46 @@ + + */ + abstract protected function getChildrenNodeSourceWalkerClassname(): string; + + /** + * @param BlocksAwareWebResponseInterface $output + * @param NodesSources $data + * @return WebResponseInterface + */ + protected function injectBlocks(BlocksAwareWebResponseInterface $output, NodesSources $data): WebResponseInterface + { + if (!$output instanceof RealmsAwareWebResponseInterface || !$output->isHidingBlocks()) { + /** @var class-string $childrenNodeSourceWalkerClassname */ + $childrenNodeSourceWalkerClassname = $this->getChildrenNodeSourceWalkerClassname(); + $output->setBlocks($childrenNodeSourceWalkerClassname::build( + $data, + $this->getWalkerContext(), + $this->getChildrenNodeSourceWalkerMaxLevel(), + $this->getCacheItemPool() + )->getChildren()); + } + + return $output; + } +} diff --git a/lib/RoadizCoreBundle/src/Api/DataTransformer/CustomFormOutputDataTransformer.php b/lib/RoadizCoreBundle/src/Api/DataTransformer/CustomFormOutputDataTransformer.php new file mode 100644 index 00000000..c4411bca --- /dev/null +++ b/lib/RoadizCoreBundle/src/Api/DataTransformer/CustomFormOutputDataTransformer.php @@ -0,0 +1,59 @@ +urlGenerator = $urlGenerator; + } + + /** + * @inheritDoc + */ + public function transform($data, string $to, array $context = []): object + { + if (!$data instanceof CustomForm) { + throw new \InvalidArgumentException('Data to transform must be instance of ' . CustomForm::class); + } + $output = new CustomFormOutput(); + $output->name = $data->getDisplayName(); + $output->color = $data->getColor(); + $output->description = $data->getDescription(); + $output->slug = (new AsciiSlugger())->slug($data->getName())->snake()->toString(); + $output->open = $data->isFormStillOpen(); + $output->definitionUrl = $this->urlGenerator->generate('api_custom_forms_item_definition', [ + 'id' => $data->getId() + ]); + $output->postUrl = $this->urlGenerator->generate('api_custom_forms_item_post', [ + 'id' => $data->getId() + ]); + + return $output; + } + + /** + * @inheritDoc + */ + public function supportsTransformation($data, string $to, array $context = []): bool + { + return CustomFormOutput::class === $to && $data instanceof CustomForm; + } +} diff --git a/lib/RoadizCoreBundle/src/Api/DataTransformer/DocumentOutputDataTransformer.php b/lib/RoadizCoreBundle/src/Api/DataTransformer/DocumentOutputDataTransformer.php new file mode 100644 index 00000000..7455219f --- /dev/null +++ b/lib/RoadizCoreBundle/src/Api/DataTransformer/DocumentOutputDataTransformer.php @@ -0,0 +1,96 @@ +documentFinder = $documentFinder; + } + + /** + * @inheritDoc + */ + public function transform($object, string $to, array $context = []): object + { + if (!$object instanceof Document) { + throw new \InvalidArgumentException('Data to transform must be instance of ' . DocumentInterface::class); + } + $output = new DocumentOutput(); + $output->relativePath = $object->getRelativePath(); + $output->processable = $object->isProcessable(); + $output->type = $object->getShortType(); + $output->imageWidth = $object->getImageWidth(); + $output->imageHeight = $object->getImageHeight(); + $output->mimeType = $object->getMimeType(); + $output->alt = $object->getFilename(); + $output->embedId = $object->getEmbedId(); + $output->embedPlatform = $object->getEmbedPlatform(); + $output->imageAverageColor = $object->getImageAverageColor(); + $output->mediaDuration = $object->getMediaDuration(); + + /** @var array $serializationGroups */ + $serializationGroups = isset($context['groups']) && is_array($context['groups']) ? $context['groups'] : []; + + if (($object->isEmbed() || !$object->isImage()) && false !== $object->getThumbnails()->first()) { + $output->thumbnail = $object->getThumbnails()->first(); + } + + if (isset($context['translation']) && $context['translation'] instanceof TranslationInterface) { + $translatedData = $object->getDocumentTranslationsByTranslation($context['translation'])->first() ?: null; + if ($translatedData instanceof DocumentTranslation) { + $output->name = $translatedData->getName(); + $output->description = $translatedData->getDescription(); + $output->copyright = $translatedData->getCopyright(); + $output->alt = !empty($translatedData->getName()) ? $translatedData->getName() : $object->getFilename(); + $output->externalUrl = $translatedData->getExternalUrl(); + } + } + + if (in_array('document_folders', $serializationGroups)) { + $output->folders = $object->getFolders()->toArray(); + } + + if (in_array('document_display_sources', $serializationGroups)) { + if ($object->isLocal() && $object->isVideo()) { + foreach ($this->documentFinder->findVideosWithFilename($object->getRelativePath()) as $document) { + if ($document->getRelativePath() !== $object->getRelativePath()) { + $output->altSources[] = $document; + } + } + } elseif ($object->isLocal() && $object->isAudio()) { + foreach ($this->documentFinder->findAudiosWithFilename($object->getRelativePath()) as $document) { + if ($document->getRelativePath() !== $object->getRelativePath()) { + $output->altSources[] = $document; + } + } + } + } + + return $output; + } + + /** + * @inheritDoc + */ + public function supportsTransformation($data, string $to, array $context = []): bool + { + return DocumentOutput::class === $to && $data instanceof DocumentInterface; + } +} diff --git a/lib/RoadizCoreBundle/src/Api/DataTransformer/FolderOutputDataTransformer.php b/lib/RoadizCoreBundle/src/Api/DataTransformer/FolderOutputDataTransformer.php new file mode 100644 index 00000000..63d8b79b --- /dev/null +++ b/lib/RoadizCoreBundle/src/Api/DataTransformer/FolderOutputDataTransformer.php @@ -0,0 +1,49 @@ +name = $data->getName(); + $output->slug = $data->getFolderName(); + $output->visible = $data->getVisible(); + $output->position = $data->getPosition(); + + if (isset($context['translation']) && $context['translation'] instanceof TranslationInterface) { + $translatedData = $data->getTranslatedFoldersByTranslation($context['translation'])->first() ?: null; + if ($translatedData instanceof FolderTranslation) { + $output->name = $translatedData->getName(); + } + } + return $output; + } + + /** + * @inheritDoc + */ + public function supportsTransformation($data, string $to, array $context = []): bool + { + return FolderOutput::class === $to && $data instanceof FolderInterface; + } +} diff --git a/lib/RoadizCoreBundle/src/Api/DataTransformer/NodeOutputDataTransformer.php b/lib/RoadizCoreBundle/src/Api/DataTransformer/NodeOutputDataTransformer.php new file mode 100644 index 00000000..3dec987d --- /dev/null +++ b/lib/RoadizCoreBundle/src/Api/DataTransformer/NodeOutputDataTransformer.php @@ -0,0 +1,39 @@ +nodeName = $data->getNodeName(); + $output->visible = $data->isVisible(); + $output->position = $data->getPosition(); + $output->tags = $data->getTags()->toArray(); + return $output; + } + + /** + * @inheritDoc + */ + public function supportsTransformation($data, string $to, array $context = []): bool + { + return NodeOutput::class === $to && $data instanceof Node; + } +} diff --git a/lib/RoadizCoreBundle/src/Api/DataTransformer/NodesSourcesOutputDataTransformer.php b/lib/RoadizCoreBundle/src/Api/DataTransformer/NodesSourcesOutputDataTransformer.php new file mode 100644 index 00000000..03c85bbd --- /dev/null +++ b/lib/RoadizCoreBundle/src/Api/DataTransformer/NodesSourcesOutputDataTransformer.php @@ -0,0 +1,52 @@ +urlGenerator = $urlGenerator; + } + + protected function transformNodesSources( + NodesSourcesDto $output, + NodesSources $data, + array $context = [] + ): NodesSourcesDto { + $output->title = $data->getTitle(); + $output->node = $data->getNode(); + $output->metaTitle = $data->getMetaTitle(); + $output->metaDescription = $data->getMetaDescription(); + $output->translation = $data->getTranslation(); + $output->slug = $data->getIdentifier(); + if ($data->isPublishable()) { + $output->publishedAt = $data->getPublishedAt(); + } + return $output; + } + + /** + * @inheritDoc + */ + public function supportsTransformation($data, string $to, array $context = []): bool + { + return $data instanceof NodesSources; + } +} diff --git a/lib/RoadizCoreBundle/src/Api/DataTransformer/RealmsAwareWebResponseOutputDataTransformerTrait.php b/lib/RoadizCoreBundle/src/Api/DataTransformer/RealmsAwareWebResponseOutputDataTransformerTrait.php new file mode 100644 index 00000000..5645704e --- /dev/null +++ b/lib/RoadizCoreBundle/src/Api/DataTransformer/RealmsAwareWebResponseOutputDataTransformerTrait.php @@ -0,0 +1,47 @@ +setRealms($this->getRealmResolver()->getRealms($data->getNode())); + $output->setHidingBlocks(false); + + $denyingRealms = array_filter($output->getRealms(), function (RealmInterface $realm) { + return $realm->getBehaviour() === RealmInterface::BEHAVIOUR_DENY; + }); + foreach ($denyingRealms as $denyingRealm) { + $this->getRealmResolver()->denyUnlessGranted($denyingRealm); + } + + $blockHidingRealms = array_filter($output->getRealms(), function (RealmInterface $realm) { + return $realm->getBehaviour() === RealmInterface::BEHAVIOUR_HIDE_BLOCKS; + }); + foreach ($blockHidingRealms as $blockHidingRealm) { + if (!$this->getRealmResolver()->isGranted($blockHidingRealm)) { + $output->setHidingBlocks(true); + } + } + + return $output; + } +} diff --git a/lib/RoadizCoreBundle/src/Api/DataTransformer/TagOutputDataTransformer.php b/lib/RoadizCoreBundle/src/Api/DataTransformer/TagOutputDataTransformer.php new file mode 100644 index 00000000..947a0acc --- /dev/null +++ b/lib/RoadizCoreBundle/src/Api/DataTransformer/TagOutputDataTransformer.php @@ -0,0 +1,53 @@ +slug = $data->getTagName(); + $output->color = $data->getColor(); + $output->visible = $data->isVisible(); + $output->position = $data->getPosition(); + if ($data->getParent() instanceof Tag) { + $output->parent = $data->getParent(); + } + + if (isset($context['translation']) && $context['translation'] instanceof TranslationInterface) { + $translatedData = $data->getTranslatedTagsByTranslation($context['translation'])->first() ?: null; + if ($translatedData instanceof TagTranslation) { + $output->name = $translatedData->getName(); + $output->description = $translatedData->getDescription(); + $output->documents = $translatedData->getDocuments(); + } + } + return $output; + } + + /** + * @inheritDoc + */ + public function supportsTransformation($data, string $to, array $context = []): bool + { + return TagOutput::class === $to && $data instanceof Tag; + } +} diff --git a/lib/RoadizCoreBundle/src/Api/DataTransformer/TranslationOutputDataTransformer.php b/lib/RoadizCoreBundle/src/Api/DataTransformer/TranslationOutputDataTransformer.php new file mode 100644 index 00000000..41f6d702 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Api/DataTransformer/TranslationOutputDataTransformer.php @@ -0,0 +1,36 @@ +locale = $data->getPreferredLocale(); + $output->defaultTranslation = $data->isDefaultTranslation(); + $output->available = $data->isAvailable(); + $output->name = $data->getName(); + return $output; + } + + /** + * @inheritDoc + */ + public function supportsTransformation($data, string $to, array $context = []): bool + { + return TranslationOutput::class === $to && $data instanceof TranslationInterface; + } +} diff --git a/lib/RoadizCoreBundle/src/Api/DataTransformer/WebResponseDataTransformerInterface.php b/lib/RoadizCoreBundle/src/Api/DataTransformer/WebResponseDataTransformerInterface.php new file mode 100644 index 00000000..a6d97c0f --- /dev/null +++ b/lib/RoadizCoreBundle/src/Api/DataTransformer/WebResponseDataTransformerInterface.php @@ -0,0 +1,28 @@ +nodesSourcesHeadFactory = $nodesSourcesHeadFactory; + $this->breadcrumbsFactory = $breadcrumbsFactory; + $this->walkerContext = $walkerContext; + $this->cacheItemPool = $cacheItemPool; + $this->urlGenerator = $urlGenerator; + $this->realmResolver = $realmResolver; + } + + protected function getWalkerContext(): WalkerContextInterface + { + return $this->walkerContext; + } + + protected function getCacheItemPool(): CacheItemPoolInterface + { + return $this->cacheItemPool; + } + + protected function getChildrenNodeSourceWalkerMaxLevel(): int + { + return 5; + } + + protected function getChildrenNodeSourceWalkerClassname(): string + { + return AutoChildrenNodeSourceWalker::class; + } + + protected function getRealmResolver(): RealmResolverInterface + { + return $this->realmResolver; + } + + + /** + * @inheritDoc + */ + public function transform($data, string $to, array $context = []): ?WebResponseInterface + { + if (!$data instanceof PersistableInterface) { + throw new \InvalidArgumentException( + 'Data to transform must be instance of ' . + PersistableInterface::class + ); + } + $output = new WebResponse(); + $output->item = $data; + if ($data instanceof NodesSources) { + $this->injectRealms($output, $data); + $this->injectBlocks($output, $data); + + $output->path = $this->urlGenerator->generate(RouteObjectInterface::OBJECT_BASED_ROUTE_NAME, [ + RouteObjectInterface::ROUTE_OBJECT => $data + ], UrlGeneratorInterface::ABSOLUTE_PATH); + $output->head = $this->nodesSourcesHeadFactory->createForNodeSource($data); + $output->breadcrumbs = $this->breadcrumbsFactory->create($data); + } + if ($data instanceof TranslationInterface) { + $output->head = $this->nodesSourcesHeadFactory->createForTranslation($data); + } + return $output; + } + + /** + * @inheritDoc + */ + public function supportsTransformation($data, string $to, array $context = []): bool + { + return WebResponseInterface::class === $to && $data instanceof PersistableInterface; + } +} diff --git a/lib/RoadizCoreBundle/src/Api/Dto/Archive.php b/lib/RoadizCoreBundle/src/Api/Dto/Archive.php new file mode 100644 index 00000000..d6204a70 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Api/Dto/Archive.php @@ -0,0 +1,16 @@ + + */ + #[Groups(['attribute'])] + public array $documents = []; +} diff --git a/lib/RoadizCoreBundle/src/Api/Dto/AttributeValueOutput.php b/lib/RoadizCoreBundle/src/Api/Dto/AttributeValueOutput.php new file mode 100644 index 00000000..9c1457eb --- /dev/null +++ b/lib/RoadizCoreBundle/src/Api/Dto/AttributeValueOutput.php @@ -0,0 +1,41 @@ + + */ + #[Groups(['document_folders'])] + public array $folders = []; + /** + * @var array + */ + #[Groups(['document_display_sources'])] + #[MaxDepth(1)] + public array $altSources = []; + /** + * @var string|null + */ + #[Groups(['document', 'document_display'])] + public ?string $alt = null; +} diff --git a/lib/RoadizCoreBundle/src/Api/Dto/FolderOutput.php b/lib/RoadizCoreBundle/src/Api/Dto/FolderOutput.php new file mode 100644 index 00000000..cd152ed1 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Api/Dto/FolderOutput.php @@ -0,0 +1,34 @@ + + */ + #[Groups(['tag', 'tag_base'])] + public array $documents = []; + /** + * @var Tag|null + */ + #[Groups(['tag', 'tag_base'])] + #[MaxDepth(1)] + public ?Tag $parent = null; + /** + * @Groups({"tag", "tag_base"}) + */ + public ?float $position = null; +} diff --git a/lib/RoadizCoreBundle/src/Api/Dto/TranslationOutput.php b/lib/RoadizCoreBundle/src/Api/Dto/TranslationOutput.php new file mode 100644 index 00000000..0abd4630 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Api/Dto/TranslationOutput.php @@ -0,0 +1,34 @@ +resourceMetadataFactory = $resourceMetadataFactory; + $this->requestStack = $requestStack; + $this->defaultPublicationFieldName = $defaultPublicationFieldName; + } + + public function applyToCollection( + QueryBuilder $queryBuilder, + QueryNameGeneratorInterface $queryNameGenerator, + string $resourceClass, + string $operationName = null + ): void { + if (!$this->supportsResult($resourceClass, $operationName)) { + return; + } + if (null === $request = $this->requestStack->getCurrentRequest()) { + return; + } + $aliases = $queryBuilder->getRootAliases(); + $alias = reset($aliases); + $publicationFieldName = $this->getPublicationFieldName($request, $this->resourceMetadataFactory->create($resourceClass), $operationName); + $publicationField = $alias . '.' . $publicationFieldName; + + $queryBuilder->select($publicationField) + ->addGroupBy($publicationField) + ->orderBy($publicationField, 'DESC'); + } + + public function supportsResult(string $resourceClass, string $operationName = null): bool + { + if (null === $request = $this->requestStack->getCurrentRequest()) { + return false; + } + + return $this->isArchiveEnabled($request, $this->resourceMetadataFactory->create($resourceClass), $operationName); + } + + public function getResult(QueryBuilder $queryBuilder): iterable + { + $entities = []; + $dates = []; + $paginator = new Paginator($queryBuilder, false); + $paginator->setUseOutputWalkers(false); + /* + * disable pagination to get all archives + */ + $paginator->getQuery()->setMaxResults(null); + $paginator->getQuery()->setFirstResult(null); + + foreach ($paginator as $result) { + $dateTimeField = reset($result); + if ($dateTimeField instanceof \DateTime) { + $year = $dateTimeField->format('Y'); + $month = $dateTimeField->format('Y-m'); + + if (!isset($dates[$year])) { + $dates[$year] = []; + } + if (!isset($dates[$year][$month])) { + $dates[$year][$month] = new \DateTime($dateTimeField->format('Y-m-01')); + } + } + } + + foreach ($dates as $year => $months) { + $entity = new Archive(); + $entity->year = $year; + $entity->months = $months; + $entities[] = $entity; + } + + return $entities; + } + + private function isArchiveEnabled( + Request $request, + ResourceMetadata $resourceMetadata, + string $operationName = null + ): bool { + return $resourceMetadata->getCollectionOperationAttribute( + $operationName, + 'archive_enabled', + false, + true + ); + } + + private function getPublicationFieldName( + Request $request, + ResourceMetadata $resourceMetadata, + string $operationName = null + ): string { + return $resourceMetadata->getCollectionOperationAttribute( + $operationName, + 'archive_publication_field_name', + $this->defaultPublicationFieldName, + true + ); + } +} diff --git a/lib/RoadizCoreBundle/src/Api/Extension/DocumentQueryExtension.php b/lib/RoadizCoreBundle/src/Api/Extension/DocumentQueryExtension.php new file mode 100644 index 00000000..8b4b0611 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Api/Extension/DocumentQueryExtension.php @@ -0,0 +1,49 @@ +andWhere($queryBuilder->expr()->eq('o.raw', ':raw')) + ->setParameter(':raw', false); + } + + public function applyToItem( + QueryBuilder $queryBuilder, + QueryNameGeneratorInterface $queryNameGenerator, + string $resourceClass, + array $identifiers, + string $operationName = null, + array $context = [] + ): void { + $this->apply($queryBuilder, $queryNameGenerator, $resourceClass, $operationName); + } + + public function applyToCollection( + QueryBuilder $queryBuilder, + QueryNameGeneratorInterface $queryNameGenerator, + string $resourceClass, + string $operationName = null + ): void { + $this->apply($queryBuilder, $queryNameGenerator, $resourceClass, $operationName); + } +} diff --git a/lib/RoadizCoreBundle/src/Api/Extension/NodeQueryExtension.php b/lib/RoadizCoreBundle/src/Api/Extension/NodeQueryExtension.php new file mode 100644 index 00000000..7b259f71 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Api/Extension/NodeQueryExtension.php @@ -0,0 +1,77 @@ +previewResolver = $previewResolver; + } + + public function applyToItem( + QueryBuilder $queryBuilder, + QueryNameGeneratorInterface $queryNameGenerator, + string $resourceClass, + array $identifiers, + string $operationName = null, + array $context = [] + ): void { + $this->apply($queryBuilder, $queryNameGenerator, $resourceClass, $operationName); + } + + private function apply( + QueryBuilder $queryBuilder, + QueryNameGeneratorInterface $queryNameGenerator, + string $resourceClass, + string $operationName = null + ): void { + if ($resourceClass !== Node::class) { + return; + } + + if ($this->previewResolver->isPreview()) { + $queryBuilder + ->andWhere($queryBuilder->expr()->lte('o.status', ':status')) + ->setParameter(':status', Node::PUBLISHED); + return; + } + + $alias = QueryBuilderHelper::addJoinOnce( + $queryBuilder, + $queryNameGenerator, + 'o', + 'nodeSources', + Join::INNER_JOIN + ); + $queryBuilder + ->andWhere($queryBuilder->expr()->lte($alias . '.publishedAt', ':lte_published_at')) + ->andWhere($queryBuilder->expr()->eq('o.status', ':status')) + ->setParameter(':lte_published_at', new \DateTime()) + ->setParameter(':status', Node::PUBLISHED); + return; + } + + public function applyToCollection( + QueryBuilder $queryBuilder, + QueryNameGeneratorInterface $queryNameGenerator, + string $resourceClass, + string $operationName = null + ): void { + $this->apply($queryBuilder, $queryNameGenerator, $resourceClass, $operationName); + } +} diff --git a/lib/RoadizCoreBundle/src/Api/Extension/NodesSourcesQueryExtension.php b/lib/RoadizCoreBundle/src/Api/Extension/NodesSourcesQueryExtension.php new file mode 100644 index 00000000..2239df4f --- /dev/null +++ b/lib/RoadizCoreBundle/src/Api/Extension/NodesSourcesQueryExtension.php @@ -0,0 +1,96 @@ +previewResolver = $previewResolver; + $this->generatedEntityNamespacePattern = $generatedEntityNamespacePattern; + } + + public function applyToItem( + QueryBuilder $queryBuilder, + QueryNameGeneratorInterface $queryNameGenerator, + string $resourceClass, + array $identifiers, + string $operationName = null, + array $context = [] + ): void { + $this->apply($queryBuilder, $queryNameGenerator, $resourceClass, $operationName); + } + + public function applyToCollection( + QueryBuilder $queryBuilder, + QueryNameGeneratorInterface $queryNameGenerator, + string $resourceClass, + string $operationName = null + ): void { + $this->apply($queryBuilder, $queryNameGenerator, $resourceClass, $operationName); + } + + private function apply( + QueryBuilder $queryBuilder, + QueryNameGeneratorInterface $queryNameGenerator, + string $resourceClass, + string $operationName = null + ): void { + if ( + $resourceClass !== NodesSources::class && + preg_match($this->generatedEntityNamespacePattern, $resourceClass) === 0 + ) { + return; + } + + if (preg_match($this->generatedEntityNamespacePattern, $resourceClass) > 0) { + $queryBuilder + ->andWhere($queryBuilder->expr()->isInstanceOf('o', $resourceClass)); + } + + if ($this->previewResolver->isPreview()) { + $alias = QueryBuilderHelper::addJoinOnce( + $queryBuilder, + $queryNameGenerator, + 'o', + 'node', + Join::INNER_JOIN + ); + $queryBuilder + ->andWhere($queryBuilder->expr()->lte($alias . '.status', ':status')) + ->setParameter(':status', Node::PUBLISHED); + return; + } + + $alias = QueryBuilderHelper::addJoinOnce( + $queryBuilder, + $queryNameGenerator, + 'o', + 'node', + Join::INNER_JOIN + ); + $queryBuilder + ->andWhere($queryBuilder->expr()->lte('o.publishedAt', ':lte_published_at')) + ->andWhere($queryBuilder->expr()->eq($alias . '.status', ':status')) + ->setParameter(':lte_published_at', new \DateTime()) + ->setParameter(':status', Node::PUBLISHED); + return; + } +} diff --git a/lib/RoadizCoreBundle/src/Api/Filter/ArchiveFilter.php b/lib/RoadizCoreBundle/src/Api/Filter/ArchiveFilter.php new file mode 100644 index 00000000..d10f8b43 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Api/Filter/ArchiveFilter.php @@ -0,0 +1,158 @@ +getDoctrineFieldType($property, $resourceClass)]); + } + + /** + * {@inheritdoc} + */ + protected function filterProperty( + string $property, + $values, + QueryBuilder $queryBuilder, + QueryNameGeneratorInterface $queryNameGenerator, + string $resourceClass, + string $operationName = null + ): void { + // Expect $values to be an array having the period as keys and the date value as values + if ( + !$this->isPropertyEnabled($property, $resourceClass) || + !$this->isPropertyMapped($property, $resourceClass) || + !$this->isDateField($property, $resourceClass) || + !isset($values[self::PARAMETER_ARCHIVE]) + ) { + return; + } + + $alias = $queryBuilder->getRootAliases()[0]; + $field = $property; + + if ($this->isPropertyNested($property, $resourceClass)) { + [$alias, $field] = $this->addJoinsForNestedProperty($property, $alias, $queryBuilder, $queryNameGenerator, $resourceClass); + } + + if (!is_string($values[self::PARAMETER_ARCHIVE])) { + throw new InvalidArgumentException(sprintf( + '“%s” filter must be only used with a string value.', + self::PARAMETER_ARCHIVE + )); + } + + $range = $this->normalizeFilteringDates($values[self::PARAMETER_ARCHIVE]); + + if (null === $range || count($range) !== 2) { + return; + } + + $valueParameter = $queryNameGenerator->generateParameterName($field); + $queryBuilder->andWhere($queryBuilder->expr()->isNotNull(sprintf('%s.%s', $alias, $field))) + ->andWhere($queryBuilder->expr()->between( + sprintf('%s.%s', $alias, $field), + ':' . $valueParameter . 'Start', + ':' . $valueParameter . 'End' + )) + ->setParameter($valueParameter . 'Start', $range[0]) + ->setParameter($valueParameter . 'End', $range[1]); + } + + /** + * Support archive parameter with year or year-month. + * + * @param string $value + * @return \DateTime[]|null + * @throws \Exception + */ + protected function normalizeFilteringDates(string $value): ?array + { + /* + * Support archive parameter with year or year-month + */ + if (preg_match('#[0-9]{4}\-[0-9]{2}\-[0-9]{2}#', $value) > 0) { + $startDate = new \DateTime($value . ' 00:00:00'); + $endDate = clone $startDate; + $endDate->add(new \DateInterval('P1D')); + + return [$startDate, $this->limitEndDate($endDate)]; + } elseif (preg_match('#[0-9]{4}\-[0-9]{2}#', $value) > 0) { + $startDate = new \DateTime($value . '-01 00:00:00'); + $endDate = clone $startDate; + $endDate->add(new \DateInterval('P1M')); + + return [$startDate, $this->limitEndDate($endDate)]; + } elseif (preg_match('#[0-9]{4}#', $value) > 0) { + $startDate = new \DateTime($value . '-01-01 00:00:00'); + $endDate = clone $startDate; + $endDate->add(new \DateInterval('P1Y')); + + return [$startDate, $this->limitEndDate($endDate)]; + } + return null; + } + + protected function limitEndDate(\DateTime $endDate): \DateTime + { + $now = new \DateTime(); + if ($endDate > $now) { + return $now; + } + return $endDate->sub(new \DateInterval('PT1S')); + } + + public function getDescription(string $resourceClass): array + { + $description = []; + + $properties = $this->getProperties(); + if (null === $properties) { + $properties = array_fill_keys($this->getClassMetadata($resourceClass)->getFieldNames(), null); + } + + foreach ($properties as $property => $nullManagement) { + if (!$this->isPropertyMapped($property, $resourceClass) || !$this->isDateField($property, $resourceClass)) { + continue; + } + + $description += $this->getFilterDescription($property); + } + + return $description; + } + + /** + * Gets filter description. + */ + protected function getFilterDescription(string $property): array + { + $propertyName = $this->normalizePropertyName($property); + + return [ + sprintf('%s[%s]', $propertyName, self::PARAMETER_ARCHIVE) => [ + 'property' => $propertyName, + 'type' => 'string', + 'required' => false, + ], + ]; + } +} diff --git a/lib/RoadizCoreBundle/src/Api/Filter/CopyrightValidFilter.php b/lib/RoadizCoreBundle/src/Api/Filter/CopyrightValidFilter.php new file mode 100644 index 00000000..513ba4fd --- /dev/null +++ b/lib/RoadizCoreBundle/src/Api/Filter/CopyrightValidFilter.php @@ -0,0 +1,77 @@ +andWhere($queryBuilder->expr()->orX( + $queryBuilder->expr()->isNull($alias . '.copyrightValidSince'), + $queryBuilder->expr()->lte($alias . '.copyrightValidSince', ':now') + ))->andWhere($queryBuilder->expr()->orX( + $queryBuilder->expr()->isNull($alias . '.copyrightValidUntil'), + $queryBuilder->expr()->gte($alias . '.copyrightValidUntil', ':now') + ))->setParameter(':now', new \DateTime()); + return; + } + + if (in_array($value, self::FALSE_VALUES)) { + // Copyright MUST NOT be valid + $queryBuilder->andWhere( + $queryBuilder->expr()->orX( + $queryBuilder->expr()->gt($alias . '.copyrightValidSince', ':now'), + $queryBuilder->expr()->lt($alias . '.copyrightValidUntil', ':now') + ) + )->setParameter(':now', new \DateTime()); + return; + } + } + + public function getDescription(string $resourceClass): array + { + return [ + self::PARAMETER => [ + 'property' => self::PARAMETER, + 'type' => 'bool', + 'required' => false, + 'description' => 'Filter items for which copyright dates are valid.', + 'openapi' => [ + 'description' => 'Filter items for which copyright dates are valid.' + ] + ] + ]; + } +} diff --git a/lib/RoadizCoreBundle/src/Api/Filter/GeneratedEntityFilter.php b/lib/RoadizCoreBundle/src/Api/Filter/GeneratedEntityFilter.php new file mode 100644 index 00000000..d34cb712 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Api/Filter/GeneratedEntityFilter.php @@ -0,0 +1,50 @@ +generatedEntityNamespacePattern = $generatedEntityNamespacePattern; + } + + /** + * @return string + */ + public function getGeneratedEntityNamespacePattern(): string + { + return $this->generatedEntityNamespacePattern; + } + + /** + * @inheritDoc + */ + public function getDescription(string $resourceClass): array + { + return []; + } +} diff --git a/lib/RoadizCoreBundle/src/Api/Filter/IntersectionFilter.php b/lib/RoadizCoreBundle/src/Api/Filter/IntersectionFilter.php new file mode 100644 index 00000000..5ba7b570 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Api/Filter/IntersectionFilter.php @@ -0,0 +1,145 @@ + $fieldValue) { + if (empty($fieldName)) { + throw new InvalidArgumentException(sprintf('“%s” filter must be only used with an associative array with fields as keys.', $property)); + } + if ($this->isPropertyEnabled($fieldName, $resourceClass)) { + // Allow single value intersection + if (!is_array($fieldValue)) { + $fieldValue = [$fieldValue]; + } + foreach ($fieldValue as $singleValue) { + [$alias, $splitFieldName] = $this->addDuplicatedJoinsForNestedProperty( + $fieldName, + 'o', + $queryBuilder, + $queryNameGenerator, + $resourceClass, + Join::INNER_JOIN // Join type must be inner to filter out empty result sets + ); + $placeholder = ':' . $alias . $splitFieldName; + $queryBuilder->andWhere($queryBuilder->expr()->eq(sprintf('%s.%s', $alias, $splitFieldName), $placeholder)); + $queryBuilder->setParameter($placeholder, $singleValue); + } + } + } + } + + /** + * @inheritDoc + */ + public function getDescription(string $resourceClass): array + { + $properties = $this->properties; + + if (null === $properties) { + $properties = array_fill_keys($this->getClassMetadata($resourceClass)->getFieldNames(), null); + } + + return array_reduce( + array_keys($properties), + function ($carry, $property) { + $carry[sprintf('%s[%s]', IntersectionFilter::PARAMETER, $property)] = [ + 'property' => $property, + 'type' => 'string', + 'required' => false, + 'description' => 'Discriminate an existing filter with additional filtering value using a new inner join.', + 'openapi' => [ + 'description' => 'Discriminate an existing filter with additional filtering value using a new inner join.' + ] + ]; + $carry[sprintf('%s[%s][]', IntersectionFilter::PARAMETER, $property)] = [ + 'property' => $property, + 'type' => 'string', + 'required' => false, + 'description' => 'Discriminate an existing filter with additional filtering value using a new inner join.', + 'openapi' => [ + 'description' => 'Discriminate an existing filter with additional filtering value using a new inner join.' + ] + ]; + return $carry; + }, + [] + ); + } + + protected function addDuplicatedJoinsForNestedProperty( + string $property, + string $rootAlias, + QueryBuilder $queryBuilder, + QueryNameGeneratorInterface $queryNameGenerator, + string $resourceClass, + string $joinType + ): array { + $propertyParts = $this->splitPropertyParts($property, $resourceClass); + $parentAlias = $rootAlias; + $alias = null; + + foreach ($propertyParts['associations'] as $association) { + $alias = self::addDuplicatedJoin($queryBuilder, $queryNameGenerator, $parentAlias, $association, $joinType); + $parentAlias = $alias; + } + + if (null === $alias) { + throw new InvalidArgumentException(sprintf('Cannot add joins for property "%s" - property is not nested.', $property)); + } + + return [$alias, $propertyParts['field'], $propertyParts['associations']]; + } + + /** + * Adds a join to the QueryBuilder if none exists. + */ + public static function addDuplicatedJoin( + QueryBuilder $queryBuilder, + QueryNameGeneratorInterface $queryNameGenerator, + string $alias, + string $association, + string $joinType = null + ): string { + $associationAlias = $queryNameGenerator->generateJoinAlias($association) . uniqid(); + $query = "$alias.$association"; + + if (Join::LEFT_JOIN === $joinType) { + $queryBuilder->leftJoin($query, $associationAlias); + } else { + $queryBuilder->innerJoin($query, $associationAlias); + } + + return $associationAlias; + } +} diff --git a/lib/RoadizCoreBundle/src/Api/Filter/LocaleFilter.php b/lib/RoadizCoreBundle/src/Api/Filter/LocaleFilter.php new file mode 100644 index 00000000..c0bd343a --- /dev/null +++ b/lib/RoadizCoreBundle/src/Api/Filter/LocaleFilter.php @@ -0,0 +1,134 @@ +previewResolver = $previewResolver; + } + + + /** + * Passes a property through the filter. + * + * @param string $property + * @param mixed $value + * @param QueryBuilder $queryBuilder + * @param QueryNameGeneratorInterface $queryNameGenerator + * @param string $resourceClass + * @param string|null $operationName + * @throws \Exception + */ + protected function filterProperty( + string $property, + $value, + QueryBuilder $queryBuilder, + QueryNameGeneratorInterface $queryNameGenerator, + string $resourceClass, + string $operationName = null + ): void { + if ($property !== self::PROPERTY) { + return; + } + + if ($this->previewResolver->isPreview()) { + $supportedLocales = $this->managerRegistry + ->getRepository(Translation::class) + ->getAllLocales(); + } else { + $supportedLocales = $this->managerRegistry + ->getRepository(Translation::class) + ->getAvailableLocales(); + } + if (!in_array($value, $supportedLocales)) { + throw new InvalidArgumentException( + sprintf( + 'Locale filter value "%s" not supported. Supported values are %s', + $value, + implode(', ', $supportedLocales) + ) + ); + } + + /* + * Apply translation filter only for NodesSources + */ + if ( + $resourceClass === NodesSources::class || + preg_match($this->getGeneratedEntityNamespacePattern(), $resourceClass) > 0 + ) { + if ($this->previewResolver->isPreview()) { + $translation = $this->managerRegistry + ->getRepository(Translation::class) + ->findOneByLocaleOrOverrideLocale($value); + } else { + $translation = $this->managerRegistry + ->getRepository(Translation::class) + ->findOneAvailableByLocaleOrOverrideLocale($value); + } + + if (null === $translation) { + throw new InvalidArgumentException('No translation exist for locale: ' . $value); + } + + $queryBuilder + ->andWhere($queryBuilder->expr()->eq('o.translation', ':translation')) + ->setParameter('translation', $translation); + } + } + + /** + * Gets the description of this filter for the given resource. + * + * Returns an array with the filter parameter names as keys and array with the following data as values: + * - property: the property where the filter is applied + * - type: the type of the filter + * - required: if this filter is required + * - strategy: the used strategy + * - swagger (optional): additional parameters for the path operation, e.g. 'swagger' => ['description' => 'My Description'] + * The description can contain additional data specific to a filter. + * + * @param string $resourceClass + * + * @return array + */ + public function getDescription(string $resourceClass): array + { + $supportedLocales = $this->managerRegistry->getRepository(Translation::class)->getAvailableLocales(); + return [ + static::PROPERTY => [ + 'property' => static::PROPERTY, + 'type' => 'string', + 'required' => false, + 'openapi' => [ + 'description' => 'Filter items with translation locale (' . implode(', ', $supportedLocales) . ').' + ] + ] + ]; + } +} diff --git a/lib/RoadizCoreBundle/src/Api/Filter/NotFilter.php b/lib/RoadizCoreBundle/src/Api/Filter/NotFilter.php new file mode 100644 index 00000000..22e37e2a --- /dev/null +++ b/lib/RoadizCoreBundle/src/Api/Filter/NotFilter.php @@ -0,0 +1,110 @@ + $notValue) { + $alias = 'o'; + $field = $property; + + if ($this->isPropertyNested($property, $resourceClass)) { + list($alias, $field) = $this->addJoinsForNestedProperty($property, $alias, $queryBuilder, $queryNameGenerator); + } + + $placeholder = ':' . (new AsciiSlugger())->slug($alias . '_' . $field, '_')->toString(); + if (\is_array($notValue)) { + $queryBuilder->andWhere( + $queryBuilder->expr()->notIn(sprintf('%s.%s', $alias, $field), $placeholder) + ); + } else { + $queryBuilder->andWhere( + $queryBuilder->expr()->neq(sprintf('%s.%s', $alias, $field), $placeholder) + ); + } + $queryBuilder->setParameter($placeholder, $notValue); + } + } + + /** + * Gets the description of this filter for the given resource. + * + * Returns an array with the filter parameter names as keys and array with the following data as values: + * - property: the property where the filter is applied + * - type: the type of the filter + * - required: if this filter is required + * - strategy: the used strategy + * - swagger (optional): additional parameters for the path operation, e.g. 'swagger' => ['description' => 'My Description'] + * The description can contain additional data specific to a filter. + * + * @param string $resourceClass + * + * @return array + */ + public function getDescription(string $resourceClass): array + { + $properties = $this->properties; + + if (null === $properties) { + $properties = array_fill_keys($this->getClassMetadata($resourceClass)->getFieldNames(), null); + } + + return array_reduce( + array_keys($properties), + function ($carry, $property) { + $carry[sprintf('%s[%s]', self::PARAMETER, $property)] = [ + 'property' => $property, + 'type' => 'string', + 'required' => false, + 'description' => 'Filter items that are not equal.', + 'openapi' => [ + 'description' => 'Filter items that are not equal.' + ] + ]; + $carry[sprintf('%s[%s][]', self::PARAMETER, $property)] = [ + 'property' => $property, + 'type' => 'string', + 'required' => false, + 'description' => 'Filter items that are not equal.', + 'openapi' => [ + 'description' => 'Filter items that are not equal.' + ] + ]; + return $carry; + }, + [] + ); + } +} diff --git a/lib/RoadizCoreBundle/src/Api/ListManager/SolrPaginator.php b/lib/RoadizCoreBundle/src/Api/ListManager/SolrPaginator.php new file mode 100644 index 00000000..015bcf57 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Api/ListManager/SolrPaginator.php @@ -0,0 +1,66 @@ +listManager = $listManager; + } + + protected function handleOnce(): void + { + if (false === $this->handled) { + $this->listManager->handle(); + $this->handled = true; + } + } + + public function count(): int + { + $this->handleOnce(); + return $this->listManager->getItemCount(); + } + + public function getLastPage(): float + { + $this->handleOnce(); + return $this->listManager->getPageCount() - 1; + } + + public function getTotalItems(): float + { + $this->handleOnce(); + return $this->listManager->getItemCount(); + } + + public function getCurrentPage(): float + { + $this->handleOnce(); + return $this->listManager->getAssignation()['currentPage']; + } + + public function getItemsPerPage(): float + { + $this->handleOnce(); + return $this->listManager->getAssignation()['itemPerPage']; + } + + public function getIterator(): \Traversable + { + $this->handleOnce(); + return new ArrayCollection($this->listManager->getEntities()); + } +} diff --git a/lib/RoadizCoreBundle/src/Api/ListManager/SolrSearchListManager.php b/lib/RoadizCoreBundle/src/Api/ListManager/SolrSearchListManager.php new file mode 100644 index 00000000..fca17cea --- /dev/null +++ b/lib/RoadizCoreBundle/src/Api/ListManager/SolrSearchListManager.php @@ -0,0 +1,101 @@ +searchHandler = $searchHandler; + $this->criteria = $criteria; + $this->searchInTags = $searchInTags; + } + + public function handle(bool $disabled = false) + { + if ($this->request === null) { + throw new \InvalidArgumentException('Cannot handle a NULL request.'); + } + + $query = trim($this->request->query->get('search') ?? ''); + + if ( + $this->request->query->has('page') && + $this->request->query->get('page') > 1 + ) { + $this->setPage((int) $this->request->query->get('page')); + } else { + $this->setPage(1); + } + + if ( + $this->request->query->has('itemsPerPage') && + $this->request->query->get('itemsPerPage') > 0 + ) { + $this->setItemPerPage((int) $this->request->query->get('itemsPerPage')); + } + + /* + * Query must be longer than 3 chars or Solr might crash + * on highlighting fields. + */ + if (strlen($query) > 3) { + $this->searchResults = $this->searchHandler->searchWithHighlight( + $query, # Use ?q query parameter to search with + $this->criteria, # a simple criteria array to filter search results + $this->getItemPerPage(), # result count + $this->searchInTags, # Search in tags too, + 10000000, + $this->getPage() + ); + } else { + $this->searchResults = $this->searchHandler->search( + $query, # Use ?q query parameter to search with + $this->criteria, # a simple criteria array to filter search results + $this->getItemPerPage(), # result count + $this->searchInTags, # Search in tags too, + 10000000, + $this->getPage() + ); + } + } + + /** + * @inheritDoc + */ + public function getItemCount(): int + { + if (null !== $this->searchResults) { + return $this->searchResults->getResultCount(); + } + throw new \InvalidArgumentException('Call EntityListManagerInterface::handle before counting entities.'); + } + + /** + * @inheritDoc + */ + public function getEntities() + { + if (null !== $this->searchResults) { + return $this->searchResults->getResultItems(); + } + throw new \InvalidArgumentException('Call EntityListManagerInterface::handle before getting entities.'); + } +} diff --git a/lib/RoadizCoreBundle/src/Api/Model/BlocksAwareWebResponseInterface.php b/lib/RoadizCoreBundle/src/Api/Model/BlocksAwareWebResponseInterface.php new file mode 100644 index 00000000..6c99e260 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Api/Model/BlocksAwareWebResponseInterface.php @@ -0,0 +1,22 @@ +|null + */ + public function getBlocks(): ?Collection; + + /** + * @param Collection|null $blocks + * @return BlocksAwareWebResponseInterface + */ + public function setBlocks(?Collection $blocks): BlocksAwareWebResponseInterface; +} diff --git a/lib/RoadizCoreBundle/src/Api/Model/NodesSourcesHead.php b/lib/RoadizCoreBundle/src/Api/Model/NodesSourcesHead.php new file mode 100644 index 00000000..7fe5b64f --- /dev/null +++ b/lib/RoadizCoreBundle/src/Api/Model/NodesSourcesHead.php @@ -0,0 +1,308 @@ +nodesSource = $nodesSource; + $this->settingsBag = $settingsBag; + $this->urlGenerator = $urlGenerator; + $this->nodeSourceApi = $nodeSourceApi; + $this->defaultTranslation = $defaultTranslation; + $this->handlerFactory = $handlerFactory; + } + + /** + * @return string|null + */ + #[Serializer\Groups(["web_response", "nodes_sources_single", "walker"])] + public function getGoogleAnalytics(): ?string + { + return $this->settingsBag->get('universal_analytics_id', null) ?? null; + } + + /** + * @return string|null + */ + #[Serializer\Groups(["web_response", "nodes_sources_single", "walker"])] + public function getGoogleTagManager(): ?string + { + return $this->settingsBag->get('google_tag_manager_id', null) ?? null; + } + + /** + * @return string|null + */ + #[Serializer\Groups(["web_response", "nodes_sources_single", "walker"])] + public function getMatomoUrl(): ?string + { + return $this->settingsBag->get('matomo_url', null) ?? null; + } + + /** + * @return string|null + */ + #[Serializer\Groups(["web_response", "nodes_sources_single", "walker"])] + public function getMatomoSiteId(): ?string + { + return $this->settingsBag->get('matomo_site_id', null) ?? null; + } + + /** + * @return string|null + */ + #[Serializer\Groups(["web_response", "nodes_sources_single", "walker"])] + public function getSiteName(): ?string + { + // site_name + return $this->settingsBag->get('site_name', null) ?? null; + } + + /** + * @return array + */ + #[Serializer\Ignore] + protected function getDefaultSeo(): array + { + if (null !== $this->nodesSource) { + $nodesSourcesHandler = $this->handlerFactory->getHandler($this->nodesSource); + if ($nodesSourcesHandler instanceof NodesSourcesHandler) { + return $nodesSourcesHandler->getSEO(); + } + } + return [ + 'title' => $this->settingsBag->get('site_name'), + 'description' => $this->settingsBag->get('seo_description'), + ]; + } + + /** + * @return string|null + */ + #[Serializer\Groups(["web_response", "nodes_sources_single", "walker"])] + public function getMetaTitle(): ?string + { + if (null === $this->seo) { + $this->seo = $this->getDefaultSeo(); + } + + return $this->seo['title']; + } + + /** + * @return string|null + */ + #[Serializer\Groups(["web_response", "nodes_sources_single", "walker"])] + public function getMetaDescription(): ?string + { + if (null === $this->seo) { + $this->seo = $this->getDefaultSeo(); + } + + return $this->seo['description']; + } + + /** + * @return bool + */ + #[Serializer\Groups(["web_response", "nodes_sources_single", "walker"])] + public function isNoIndex(): bool + { + if (null !== $this->nodesSource) { + return $this->nodesSource->isNoIndex(); + } + return false; + } + + /** + * @return string|null + */ + #[Serializer\Groups(["web_response", "nodes_sources_single", "walker"])] + public function getPolicyUrl(): ?string + { + $translation = $this->getTranslation(); + + $policyNodeSource = $this->nodeSourceApi->getOneBy([ + 'node.nodeName' => 'privacy', + 'translation' => $translation + ]); + if (null === $policyNodeSource) { + $policyNodeSource = $this->nodeSourceApi->getOneBy([ + 'node.nodeName' => 'legal', + 'translation' => $translation + ]); + } + if (null !== $policyNodeSource) { + return $this->urlGenerator->generate(RouteObjectInterface::OBJECT_BASED_ROUTE_NAME, [ + RouteObjectInterface::ROUTE_OBJECT => $policyNodeSource + ]); + } + return null; + } + + /** + * @return string|null + */ + #[Serializer\Groups(["web_response", "nodes_sources_single", "walker"])] + public function getMainColor(): ?string + { + return $this->settingsBag->get('main_color', null) ?? null; + } + + /** + * @return string|null + */ + #[Serializer\Groups(["web_response", "nodes_sources_single", "walker"])] + public function getFacebookUrl(): ?string + { + return $this->settingsBag->get('facebook_url', null) ?? null; + } + + /** + * @return string|null + */ + #[Serializer\Groups(["web_response", "nodes_sources_single", "walker"])] + public function getInstagramUrl(): ?string + { + return $this->settingsBag->get('instagram_url', null) ?? null; + } + + /** + * @return string|null + */ + #[Serializer\Groups(["web_response", "nodes_sources_single", "walker"])] + public function getTwitterUrl(): ?string + { + return $this->settingsBag->get('twitter_url', null) ?? null; + } + + /** + * @return string|null + */ + #[Serializer\Groups(["web_response", "nodes_sources_single", "walker"])] + public function getYoutubeUrl(): ?string + { + return $this->settingsBag->get('youtube_url', null) ?? null; + } + + /** + * @return string|null + */ + #[Serializer\Groups(["nodes_sources_single", "walker"])] + public function getLinkedinUrl(): ?string + { + return $this->settingsBag->get('linkedin_url', null) ?? null; + } + + /** + * @return string|null + */ + #[Serializer\Groups(["web_response", "nodes_sources_single", "walker"])] + public function getHomePageUrl(): ?string + { + $homePage = $this->getHomePage(); + if (null !== $homePage) { + return $this->urlGenerator->generate(RouteObjectInterface::OBJECT_BASED_ROUTE_NAME, [ + RouteObjectInterface::ROUTE_OBJECT => $homePage + ]); + } + return null; + } + + /** + * @return DocumentInterface|null + */ + #[Serializer\Groups(["web_response", "nodes_sources_single"])] + public function getShareImage(): ?DocumentInterface + { + if ( + null !== $this->nodesSource && + method_exists($this->nodesSource, 'getHeaderImage') && + isset($this->nodesSource->getHeaderImage()[0]) + ) { + return $this->nodesSource->getHeaderImage()[0]; + } + if ( + null !== $this->nodesSource && + method_exists($this->nodesSource, 'getImage') && + isset($this->nodesSource->getImage()[0]) + ) { + return $this->nodesSource->getImage()[0]; + } + return $this->settingsBag->getDocument('share_image') ?? null; + } + + /** + * @return TranslationInterface + */ + #[Serializer\Ignore()] + public function getTranslation(): TranslationInterface + { + if (null !== $this->nodesSource) { + return $this->nodesSource->getTranslation(); + } + return $this->defaultTranslation; + } + + /** + * @return NodesSources|null + */ + #[Serializer\Ignore()] + public function getHomePage(): ?NodesSources + { + return $this->nodeSourceApi->getOneBy([ + 'node.home' => true, + 'translation' => $this->getTranslation() + ]); + } +} diff --git a/lib/RoadizCoreBundle/src/Api/Model/NodesSourcesHeadFactory.php b/lib/RoadizCoreBundle/src/Api/Model/NodesSourcesHeadFactory.php new file mode 100644 index 00000000..b6040a82 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Api/Model/NodesSourcesHeadFactory.php @@ -0,0 +1,62 @@ +settingsBag = $settingsBag; + $this->urlGenerator = $urlGenerator; + $this->nodeSourceApi = $nodeSourceApi; + $this->handlerFactory = $handlerFactory; + } + + public function createForNodeSource(NodesSources $nodesSources): NodesSourcesHeadInterface + { + return new NodesSourcesHead( + $nodesSources, + $this->settingsBag, + $this->urlGenerator, + $this->nodeSourceApi, + $this->handlerFactory, + $nodesSources->getTranslation() + ); + } + + public function createForTranslation(TranslationInterface $translation): NodesSourcesHeadInterface + { + return new NodesSourcesHead( + null, + $this->settingsBag, + $this->urlGenerator, + $this->nodeSourceApi, + $this->handlerFactory, + $translation + ); + } +} diff --git a/lib/RoadizCoreBundle/src/Api/Model/NodesSourcesHeadInterface.php b/lib/RoadizCoreBundle/src/Api/Model/NodesSourcesHeadInterface.php new file mode 100644 index 00000000..54c5881f --- /dev/null +++ b/lib/RoadizCoreBundle/src/Api/Model/NodesSourcesHeadInterface.php @@ -0,0 +1,21 @@ +|null + */ + #[Serializer\Groups(["web_response"])] + private ?Collection $blocks = null; + /** + * @var array|null + */ + #[Serializer\Groups(["web_response"])] + private ?array $realms = null; + + #[Serializer\Groups(["web_response"])] + private bool $hidingBlocks = false; + + /** + * @return PersistableInterface|null + */ + public function getItem(): ?PersistableInterface + { + return $this->item; + } + + /** + * @return Collection|null + */ + public function getBlocks(): ?Collection + { + return $this->blocks; + } + + /** + * @param Collection|null $blocks + * @return WebResponse + */ + public function setBlocks(?Collection $blocks): WebResponse + { + $this->blocks = $blocks; + return $this; + } + + /** + * @return RealmInterface[]|null + */ + public function getRealms(): ?array + { + return $this->realms; + } + + /** + * @param RealmInterface[]|null $realms + * @return WebResponse + */ + public function setRealms(?array $realms): WebResponse + { + $this->realms = $realms; + return $this; + } + + /** + * @return bool + */ + public function isHidingBlocks(): bool + { + return $this->hidingBlocks; + } + + /** + * @param bool $hidingBlocks + * @return WebResponse + */ + public function setHidingBlocks(bool $hidingBlocks): WebResponse + { + $this->hidingBlocks = $hidingBlocks; + return $this; + } +} diff --git a/lib/RoadizCoreBundle/src/Api/Model/WebResponseInterface.php b/lib/RoadizCoreBundle/src/Api/Model/WebResponseInterface.php new file mode 100644 index 00000000..589b7983 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Api/Model/WebResponseInterface.php @@ -0,0 +1,12 @@ +decorated = $decorated; + } + + public function __invoke(array $context = []): OpenApi + { + $openApi = ($this->decorated)($context); + $schemas = $openApi->getComponents()->getSchemas(); + + $schemas['TokenResponse'] = new \ArrayObject([ + 'type' => 'object', + 'properties' => [ + 'token' => [ + 'type' => 'string', + 'readOnly' => true, + ], + ], + ]); + + $schemas['InvalidCredentialsResponse'] = new \ArrayObject([ + 'type' => 'object', + 'properties' => [ + 'code' => [ + 'type' => 'string', + 'readOnly' => true, + 'example' => '401', + ], + 'message' => [ + 'type' => 'string', + 'readOnly' => true, + 'example' => 'Invalid credentials', + ], + ], + ]); + $schemas['CredentialsRequest'] = new \ArrayObject([ + 'type' => 'object', + 'properties' => [ + 'username' => [ + 'type' => 'string', + 'example' => 'johndoe@example.com', + ], + 'password' => [ + 'type' => 'string', + 'example' => 'apassword', + ], + ], + ]); + + $schemas = $openApi->getComponents()->getSecuritySchemes() ?? []; + $schemas['JWT'] = new \ArrayObject([ + 'type' => 'http', + 'scheme' => 'bearer', + 'bearerFormat' => 'JWT', + ]); + + $pathItem = new Model\PathItem( + ref: 'JWT Token', + post: new Model\Operation( + operationId: 'postCredentialsItem', + tags: ['Authentication'], + responses: [ + '401' => [ + 'description' => 'Invalid credentials', + 'content' => [ + 'application/json' => [ + 'schema' => [ + '$ref' => '#/components/schemas/InvalidCredentialsResponse', + ], + ], + ], + ], + '200' => [ + 'description' => 'JWT token', + 'content' => [ + 'application/json' => [ + 'schema' => [ + '$ref' => '#/components/schemas/TokenResponse', + ], + ], + ], + ], + ], + summary: 'Get JWT token to login.', + requestBody: new Model\RequestBody( + description: 'Generate new JWT Token', + content: new \ArrayObject([ + 'application/json' => [ + 'schema' => [ + '$ref' => '#/components/schemas/CredentialsRequest', + ], + ], + ]), + ), + security: [], + ), + ); + + /* + * Make sure OpenApi path is the same as API firewall json login: + * security.firewalls.api.json_login.check_path + */ + $openApi->getPaths()->addPath('/api/token', $pathItem); + + return $openApi; + } +} diff --git a/lib/RoadizCoreBundle/src/Api/TreeWalker/AutoChildrenNodeSourceWalker.php b/lib/RoadizCoreBundle/src/Api/TreeWalker/AutoChildrenNodeSourceWalker.php new file mode 100644 index 00000000..96ea462d --- /dev/null +++ b/lib/RoadizCoreBundle/src/Api/TreeWalker/AutoChildrenNodeSourceWalker.php @@ -0,0 +1,65 @@ +isRoot()) { + $context = $this->getContext(); + if ($context instanceof NodeSourceWalkerContext) { + /** @var NodeTypeInterface $nodeType */ + foreach ($context->getNodeTypesBag()->all() as $nodeType) { + $this->addDefinition( + $nodeType->getSourceEntityFullQualifiedClassName(), + $this->createDefinitionForNodeType($nodeType) + ); + } + + $this->initializeAdditionalDefinitions(); + } + } + } + + protected function initializeAdditionalDefinitions(): void + { + // override this for custom tree-walker definitions + } + + /** + * @param NodeTypeInterface $nodeType + * @return callable + * @throws InvalidArgumentException + */ + protected function createDefinitionForNodeType(NodeTypeInterface $nodeType): callable + { + $context = $this->getContext(); + if (!$context instanceof NodeSourceWalkerContext) { + throw new \InvalidArgumentException( + 'TreeWalker context must be instance of ' . + NodeSourceWalkerContext::class + ); + } + $childrenNodeTypes = $context->getNodeTypeResolver()->getChildrenNodeTypeList($nodeType); + if (count($childrenNodeTypes) > 0) { + return new MultiTypeChildrenDefinition($this->getContext(), $childrenNodeTypes); + } + + return new ZeroChildrenDefinition($this->getContext()); + } +} diff --git a/lib/RoadizCoreBundle/src/Api/TreeWalker/Definition/MultiTypeChildrenDefinition.php b/lib/RoadizCoreBundle/src/Api/TreeWalker/Definition/MultiTypeChildrenDefinition.php new file mode 100644 index 00000000..a71eecfe --- /dev/null +++ b/lib/RoadizCoreBundle/src/Api/TreeWalker/Definition/MultiTypeChildrenDefinition.php @@ -0,0 +1,57 @@ + $types + */ + public function __construct(WalkerContextInterface $context, array $types) + { + $this->context = $context; + $this->types = $types; + } + + /** + * @param NodesSources $source + * @return array|Paginator + */ + public function __invoke(NodesSources $source) + { + if ($this->context instanceof NodeSourceWalkerContext) { + $this->context->getStopwatch()->start(self::class); + $bag = $this->context->getNodeTypesBag(); + $children = $this->context->getNodeSourceApi()->getBy([ + 'node.parent' => $source->getNode(), + 'node.visible' => true, + 'translation' => $source->getTranslation(), + 'node.nodeType' => array_map(function (string $singleType) use ($bag) { + return $bag->get($singleType); + }, $this->types) + ], [ + 'node.position' => 'ASC', + ]); + $this->context->getStopwatch()->stop(self::class); + + return $children; + } + throw new \InvalidArgumentException('Context should be instance of ' . NodeSourceWalkerContext::class); + } +} diff --git a/lib/RoadizCoreBundle/src/Api/TreeWalker/Definition/NonReachableNodeSourceBlockDefinition.php b/lib/RoadizCoreBundle/src/Api/TreeWalker/Definition/NonReachableNodeSourceBlockDefinition.php new file mode 100644 index 00000000..1d065766 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Api/TreeWalker/Definition/NonReachableNodeSourceBlockDefinition.php @@ -0,0 +1,45 @@ +context instanceof NodeSourceWalkerContext) { + $this->context->getStopwatch()->start(self::class); + $children = $this->context->getNodeSourceApi()->getBy([ + 'node.parent' => $source->getNode(), + 'node.visible' => true, + 'translation' => $source->getTranslation(), + 'node.nodeType.reachable' => false, + ], [ + 'node.position' => 'ASC', + ]); + $this->context->getStopwatch()->stop(self::class); + + if ($children instanceof Paginator) { + $iterator = $children->getIterator(); + if ($iterator instanceof ArrayIterator) { + return $iterator->getArrayCopy(); + } + } + return $children; + } + throw new \InvalidArgumentException('Context should be instance of ' . NodeSourceWalkerContext::class); + } +} diff --git a/lib/RoadizCoreBundle/src/Api/TreeWalker/Definition/ReachableNodeSourceDefinition.php b/lib/RoadizCoreBundle/src/Api/TreeWalker/Definition/ReachableNodeSourceDefinition.php new file mode 100644 index 00000000..acf30de3 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Api/TreeWalker/Definition/ReachableNodeSourceDefinition.php @@ -0,0 +1,45 @@ +context instanceof NodeSourceWalkerContext) { + $this->context->getStopwatch()->start(self::class); + $children = $this->context->getNodeSourceApi()->getBy([ + 'node.parent' => $source->getNode(), + 'node.visible' => true, + 'translation' => $source->getTranslation(), + 'node.nodeType.reachable' => true + ], [ + 'node.position' => 'ASC', + ]); + $this->context->getStopwatch()->stop(self::class); + + if ($children instanceof Paginator) { + $iterator = $children->getIterator(); + if ($iterator instanceof ArrayIterator) { + return $iterator->getArrayCopy(); + } + } + return $children; + } + throw new \InvalidArgumentException('Context should be instance of ' . NodeSourceWalkerContext::class); + } +} diff --git a/lib/RoadizCoreBundle/src/Api/TreeWalker/NodeSourceWalkerContext.php b/lib/RoadizCoreBundle/src/Api/TreeWalker/NodeSourceWalkerContext.php new file mode 100644 index 00000000..31dfd730 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Api/TreeWalker/NodeSourceWalkerContext.php @@ -0,0 +1,126 @@ +stopwatch = $stopwatch; + $this->nodeTypesBag = $nodeTypesBag; + $this->nodeSourceApi = $nodeSourceApi; + $this->requestStack = $requestStack; + $this->managerRegistry = $managerRegistry; + $this->cacheAdapter = $cacheAdapter; + $this->nodeTypeResolver = $nodeTypeResolver; + } + + /** + * @return Stopwatch + */ + public function getStopwatch(): Stopwatch + { + return $this->stopwatch; + } + + /** + * @return NodeTypes + */ + public function getNodeTypesBag(): NodeTypes + { + return $this->nodeTypesBag; + } + + /** + * @return NodeSourceApi + */ + public function getNodeSourceApi(): NodeSourceApi + { + return $this->nodeSourceApi; + } + + /** + * @return RequestStack + */ + public function getRequestStack(): RequestStack + { + return $this->requestStack; + } + + /** + * @return Request|null + * @deprecated Use getMainRequest + */ + public function getMasterRequest(): ?Request + { + return $this->requestStack->getMainRequest(); + } + + /** + * @return Request|null + */ + public function getMainRequest(): ?Request + { + return $this->requestStack->getMainRequest(); + } + + /** + * @return ManagerRegistry + */ + public function getManagerRegistry(): ManagerRegistry + { + return $this->managerRegistry; + } + + /** + * @return ObjectManager + */ + public function getEntityManager(): ObjectManager + { + return $this->getManagerRegistry()->getManager(); + } + + /** + * @return CacheItemPoolInterface + */ + public function getCacheAdapter(): CacheItemPoolInterface + { + return $this->cacheAdapter; + } + + /** + * @return NodeTypeResolver + */ + public function getNodeTypeResolver(): NodeTypeResolver + { + return $this->nodeTypeResolver; + } +} diff --git a/lib/RoadizCoreBundle/src/Api/TreeWalker/NodeSourceWalkerContextFactory.php b/lib/RoadizCoreBundle/src/Api/TreeWalker/NodeSourceWalkerContextFactory.php new file mode 100644 index 00000000..e78e8ed9 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Api/TreeWalker/NodeSourceWalkerContextFactory.php @@ -0,0 +1,56 @@ +stopwatch = $stopwatch; + $this->nodeTypesBag = $nodeTypesBag; + $this->nodeSourceApi = $nodeSourceApi; + $this->requestStack = $requestStack; + $this->managerRegistry = $managerRegistry; + $this->cacheAdapter = $cacheAdapter; + $this->nodeTypeResolver = $nodeTypeResolver; + } + + public function createWalkerContext(): WalkerContextInterface + { + return new NodeSourceWalkerContext( + $this->stopwatch, + $this->nodeTypesBag, + $this->nodeSourceApi, + $this->requestStack, + $this->managerRegistry, + $this->cacheAdapter, + $this->nodeTypeResolver + ); + } +} diff --git a/lib/RoadizCoreBundle/src/Api/TreeWalker/TreeWalkerGenerator.php b/lib/RoadizCoreBundle/src/Api/TreeWalker/TreeWalkerGenerator.php new file mode 100644 index 00000000..1e357445 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Api/TreeWalker/TreeWalkerGenerator.php @@ -0,0 +1,73 @@ +nodeSourceApi = $nodeSourceApi; + $this->nodeTypesBag = $nodeTypesBag; + $this->walkerContext = $walkerContext; + $this->cacheItemPool = $cacheItemPool; + } + + /** + * @param string $nodeType + * @param class-string $walkerClass + * @param TranslationInterface $translation + * @param int $maxLevel + * @return array + */ + public function getTreeWalkersForTypeAtRoot( + string $nodeType, + string $walkerClass, + TranslationInterface $translation, + int $maxLevel = 3 + ): array { + $walkers = []; + /** @var NodesSources[] $roots */ + $roots = $this->nodeSourceApi->getBy([ + 'node.nodeType' => $this->nodeTypesBag->get($nodeType), + 'node.parent' => null, + 'translation' => $translation, + ]); + + foreach ($roots as $root) { + $walkerName = (new UnicodeString($root->getNode()?->getNodeName() . ' walker')) + ->trim() + ->camel() + ->toString(); + + $walkers[$walkerName] = call_user_func( + [$walkerClass, 'build'], + $root, + $this->walkerContext, + $maxLevel, + $this->cacheItemPool + ); + } + + return $walkers; + } +} diff --git a/lib/RoadizCoreBundle/src/Api/TreeWalker/WalkerContextFactoryInterface.php b/lib/RoadizCoreBundle/src/Api/TreeWalker/WalkerContextFactoryInterface.php new file mode 100644 index 00000000..889481d8 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Api/TreeWalker/WalkerContextFactoryInterface.php @@ -0,0 +1,12 @@ +managerRegistry = $managerRegistry; + } + + /** + * @return NodeTypeRepository + */ + public function getRepository(): NodeTypeRepository + { + if (null === $this->repository) { + $this->repository = $this->managerRegistry->getRepository(NodeType::class); + } + return $this->repository; + } + + protected function populateParameters(): void + { + try { + $nodeTypes = $this->getRepository()->findAll(); + $this->parameters = []; + /** @var NodeType $nodeType */ + foreach ($nodeTypes as $nodeType) { + $this->parameters[$nodeType->getName()] = $nodeType; + $this->parameters[$nodeType->getSourceEntityFullQualifiedClassName()] = $nodeType; + } + } catch (DBALException $e) { + $this->parameters = []; + } + $this->ready = true; + } +} diff --git a/lib/RoadizCoreBundle/src/Bag/Roles.php b/lib/RoadizCoreBundle/src/Bag/Roles.php new file mode 100644 index 00000000..13187bca --- /dev/null +++ b/lib/RoadizCoreBundle/src/Bag/Roles.php @@ -0,0 +1,75 @@ +managerRegistry = $managerRegistry; + } + + /** + * @return RoleRepository + */ + public function getRepository(): RoleRepository + { + if (null === $this->repository) { + $this->repository = $this->managerRegistry->getRepository(Role::class); + } + return $this->repository; + } + + protected function populateParameters(): void + { + try { + $roles = $this->getRepository()->findAll(); + $this->parameters = []; + /** @var Role $role */ + foreach ($roles as $role) { + $this->parameters[$role->getRole()] = $role; + } + } catch (DBALException $e) { + $this->parameters = []; + } + $this->ready = true; + } + + /** + * Get role by name or create it if non-existent. + * + * @param string $key + * @param null $default + * + * @return Role + * @throws \Doctrine\ORM\ORMException + * @throws \Doctrine\ORM\OptimisticLockException + */ + public function get($key, $default = null): Role + { + $role = parent::get($key, $default); + + if (null === $role) { + $role = new Role($key); + $this->managerRegistry->getManagerForClass(Role::class)->persist($role); + $this->managerRegistry->getManagerForClass(Role::class)->flush(); + } + + return $role; + } +} diff --git a/lib/RoadizCoreBundle/src/Bag/Settings.php b/lib/RoadizCoreBundle/src/Bag/Settings.php new file mode 100644 index 00000000..b80714d5 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Bag/Settings.php @@ -0,0 +1,81 @@ +managerRegistry = $managerRegistry; + } + + /** + * @return SettingRepository + */ + public function getRepository(): SettingRepository + { + if (null === $this->repository) { + $this->repository = $this->managerRegistry->getRepository(Setting::class); + } + return $this->repository; + } + + protected function populateParameters(): void + { + try { + $settings = $this->getRepository()->findAll(); + $this->parameters = []; + /** @var Setting $setting */ + foreach ($settings as $setting) { + $this->parameters[$setting->getName()] = $setting->getValue(); + } + } catch (DBALException $e) { + $this->parameters = []; + } + $this->ready = true; + } + + /** + * @param string $key + * @param mixed $default + * @return bool|mixed + */ + public function get($key, $default = false) + { + return parent::get($key, $default); + } + + /** + * Get a document from its setting name. + * + * @param string $key + * @return Document|null + */ + public function getDocument($key): ?Document + { + try { + $id = $this->getInt($key); + return $this->managerRegistry + ->getRepository(Document::class) + ->findOneById($id); + } catch (\Exception $e) { + return null; + } + } +} diff --git a/lib/RoadizCoreBundle/src/Cache/Clearer/AssetsFileClearer.php b/lib/RoadizCoreBundle/src/Cache/Clearer/AssetsFileClearer.php new file mode 100644 index 00000000..ac664241 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Cache/Clearer/AssetsFileClearer.php @@ -0,0 +1,27 @@ +exists($this->getCacheDir())) { + $finder->in($this->getCacheDir()); + $fs->remove($finder); + $this->output .= 'Assets cache has been purged.'; + + return true; + } + + return false; + } +} diff --git a/lib/RoadizCoreBundle/src/Cache/Clearer/ClearerInterface.php b/lib/RoadizCoreBundle/src/Cache/Clearer/ClearerInterface.php new file mode 100644 index 00000000..3b76c801 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Cache/Clearer/ClearerInterface.php @@ -0,0 +1,23 @@ +cacheDir = $cacheDir; + } + + public function clear(): bool + { + return false; + } + + public function getOutput(): string + { + return $this->output ?? ''; + } + + /** + * Get global cache directory. + * + * @return string + */ + public function getCacheDir(): string + { + return $this->cacheDir; + } +} diff --git a/lib/RoadizCoreBundle/src/Cache/Clearer/NodesSourcesUrlsCacheClearer.php b/lib/RoadizCoreBundle/src/Cache/Clearer/NodesSourcesUrlsCacheClearer.php new file mode 100644 index 00000000..f59f919b --- /dev/null +++ b/lib/RoadizCoreBundle/src/Cache/Clearer/NodesSourcesUrlsCacheClearer.php @@ -0,0 +1,29 @@ +cacheProvider = $cacheProvider; + } + + public function clear(): bool + { + $this->output .= 'Node-sources URLs cache: '; + + if ($this->cacheProvider->clear()) { + $this->output .= 'cleared'; + return true; + } + return false; + } +} diff --git a/lib/RoadizCoreBundle/src/Cache/Clearer/OPCacheClearer.php b/lib/RoadizCoreBundle/src/Cache/Clearer/OPCacheClearer.php new file mode 100644 index 00000000..9d165804 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Cache/Clearer/OPCacheClearer.php @@ -0,0 +1,40 @@ +output = 'PHP OPCache has been reset.'; + return true; + } else { + $this->output = 'PHP OPCache is disabled.'; + } + + return false; + } + + public function getOutput(): string + { + return $this->output; + } + + public function getCacheDir(): string + { + return ''; + } +} diff --git a/lib/RoadizCoreBundle/src/Cache/CloudflareProxyCache.php b/lib/RoadizCoreBundle/src/Cache/CloudflareProxyCache.php new file mode 100644 index 00000000..15e63174 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Cache/CloudflareProxyCache.php @@ -0,0 +1,92 @@ +name = $name; + $this->zone = $zone; + $this->version = $version; + $this->bearer = $bearer; + $this->email = $email; + $this->key = $key; + $this->timeout = $timeout; + } + + /** + * @return string + */ + public function getName(): string + { + return $this->name; + } + + /** + * @return string + */ + public function getZone(): string + { + return $this->zone; + } + + /** + * @return string + */ + public function getVersion(): string + { + return $this->version; + } + + /** + * @return string + */ + public function getBearer(): string + { + return $this->bearer; + } + + /** + * @return string + */ + public function getEmail(): string + { + return $this->email; + } + + /** + * @return string + */ + public function getKey(): string + { + return $this->key; + } + + /** + * @return int + */ + public function getTimeout(): int + { + return $this->timeout; + } +} diff --git a/lib/RoadizCoreBundle/src/Cache/ReverseProxyCache.php b/lib/RoadizCoreBundle/src/Cache/ReverseProxyCache.php new file mode 100644 index 00000000..80900a63 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Cache/ReverseProxyCache.php @@ -0,0 +1,51 @@ +name = $name; + $this->host = $host; + $this->domainName = $domainName; + $this->timeout = $timeout; + } + + /** + * @return string + */ + public function getName(): string + { + return $this->name; + } + + /** + * @return string + */ + public function getHost(): string + { + return $this->host; + } + + /** + * @return string + */ + public function getDomainName(): string + { + return $this->domainName; + } +} diff --git a/lib/RoadizCoreBundle/src/Cache/ReverseProxyCacheLocator.php b/lib/RoadizCoreBundle/src/Cache/ReverseProxyCacheLocator.php new file mode 100644 index 00000000..b6a4a3f6 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Cache/ReverseProxyCacheLocator.php @@ -0,0 +1,40 @@ +frontends = $frontends; + $this->cloudflareProxyCache = $cloudflareProxyCache; + } + + /** + * @return ReverseProxyCache[] + */ + public function getFrontends(): array + { + return $this->frontends; + } + + /** + * @return CloudflareProxyCache|null + */ + public function getCloudflareProxyCache(): ?CloudflareProxyCache + { + return $this->cloudflareProxyCache; + } +} diff --git a/lib/RoadizCoreBundle/src/Configuration/CollectionFieldConfiguration.php b/lib/RoadizCoreBundle/src/Configuration/CollectionFieldConfiguration.php new file mode 100644 index 00000000..faf08a15 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Configuration/CollectionFieldConfiguration.php @@ -0,0 +1,28 @@ +getRootNode(); + $root->children() + ->scalarNode('entry_type') + ->isRequired() + ->cannotBeEmpty() + ->info('Full qualified class name for the AbstractType class.') + ->end(); + + return $builder; + } +} diff --git a/lib/RoadizCoreBundle/src/Configuration/JoinNodeTypeFieldConfiguration.php b/lib/RoadizCoreBundle/src/Configuration/JoinNodeTypeFieldConfiguration.php new file mode 100644 index 00000000..952c6447 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Configuration/JoinNodeTypeFieldConfiguration.php @@ -0,0 +1,84 @@ +getRootNode(); + $root->children() + ->scalarNode('classname') + ->isRequired() + ->cannotBeEmpty() + ->info('Full qualified class name for Doctrine entity.') + ->end() + ->scalarNode('displayable') + ->isRequired() + ->cannotBeEmpty() + ->info('Method name to display entity name/title as a string.') + ->end() + ->scalarNode('alt_displayable') + ->info('Method name to display entity secondary information as a string.') + ->end() + ->scalarNode('thumbnail') + ->info('Method name to display a thumbnail document.') + ->end() + ->arrayNode('searchable') + ->requiresAtLeastOneElement() + ->prototype('scalar') + ->cannotBeEmpty() + ->end() + ->info('Searchable entity fields for entity explorer.') + ->end() + ->arrayNode('where') + ->prototype('array') + ->children() + ->scalarNode('field')->end() + ->scalarNode('value')->end() + ->end() + ->end() + ->end() + ->arrayNode('orderBy') + ->prototype('array') + ->children() + ->scalarNode('field')->end() + ->scalarNode('direction')->end() + ->end() + ->end() + ->end() + ->arrayNode('proxy') + ->children() + ->scalarNode('classname') + ->info('Full qualified class name for Doctrine proxy entity.') + ->end() + ->scalarNode('relation') + ->info('Field name to link external entity.') + ->end() + ->scalarNode('self') + ->info('Field name to link self entity.') + ->end() + ->arrayNode('orderBy') + ->prototype('array') + ->children() + ->scalarNode('field')->end() + ->scalarNode('direction')->end() + ->end() + ->end() + ->end() + ->end() + ->end() + ; + + return $builder; + } +} diff --git a/lib/RoadizCoreBundle/src/Configuration/ProviderFieldConfiguration.php b/lib/RoadizCoreBundle/src/Configuration/ProviderFieldConfiguration.php new file mode 100644 index 00000000..c9a5d00f --- /dev/null +++ b/lib/RoadizCoreBundle/src/Configuration/ProviderFieldConfiguration.php @@ -0,0 +1,45 @@ +getRootNode(); + $root->addDefaultsIfNotSet(); + $root->children() + ->scalarNode('classname') + ->isRequired() + ->cannotBeEmpty() + ->info('Full qualified class name for the Provider class.') + ->end() + ->arrayNode('options') + ->arrayPrototype() + ->children() + ->scalarNode('name') + ->cannotBeEmpty() + ->isRequired() + ->info('Additional option name.') + ->end() + ->scalarNode('value') + ->defaultNull() + ->end() + ->end() + ->end() + ->defaultValue([]) + ->info('Additional options to pass to Provider class.') + ->end(); + + return $builder; + } +} diff --git a/lib/RoadizCoreBundle/src/Console/CleanLoginAttemptCommand.php b/lib/RoadizCoreBundle/src/Console/CleanLoginAttemptCommand.php new file mode 100644 index 00000000..758ac145 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Console/CleanLoginAttemptCommand.php @@ -0,0 +1,43 @@ +managerRegistry = $managerRegistry; + } + + protected function configure(): void + { + $this->setName('login-attempts:clean') + ->setDescription('Clean all login attempts older than 1 day'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $this->managerRegistry->getRepository(LoginAttempt::class)->cleanLoginAttempts(); + + $io->success('All login attempts older than 1 day were deleted.'); + + return 0; + } +} diff --git a/lib/RoadizCoreBundle/src/Console/CustomFormAnswerPurgeCommand.php b/lib/RoadizCoreBundle/src/Console/CustomFormAnswerPurgeCommand.php new file mode 100644 index 00000000..5363c503 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Console/CustomFormAnswerPurgeCommand.php @@ -0,0 +1,124 @@ +managerRegistry = $managerRegistry; + $this->eventDispatcher = $eventDispatcher; + $this->logger = $logger; + } + + protected function configure(): void + { + $this->setName('custom-form-answer:prune') + ->setDescription('Prune all custom-form answers older than custom-form retention time policy.') + ->addOption('dry-run', 'd', InputOption::VALUE_NONE) + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $customForms = $this->managerRegistry + ->getRepository(CustomForm::class) + ->findAllWithRetentionTime(); + + foreach ($customForms as $customForm) { + if (null !== $interval = $customForm->getRetentionTimeInterval()) { + $purgeBefore = (new \DateTime())->sub($interval); + $customFormAnswers = $this->managerRegistry + ->getRepository(CustomFormAnswer::class) + ->findByCustomFormSubmittedBefore($customForm, $purgeBefore); + $count = count($customFormAnswers); + + $documents = $this->managerRegistry + ->getRepository(Document::class) + ->findByCustomFormSubmittedBefore($customForm, $purgeBefore); + $documentsCount = count($documents); + + if ($output->isVeryVerbose()) { + $io->info(\sprintf( + 'Checking if “%s” custom-form has answers before %s', + $customForm->getName(), + $purgeBefore->format('Y-m-d H:i') + )); + } + + if ($count > 0) { + $io->info(\sprintf( + 'Purge %d custom-form answer(s) with %d documents(s) from “%s” before %s', + $count, + $documentsCount, + $customForm->getName(), + $purgeBefore->format('Y-m-d H:i') + )); + + if ( + !$input->getOption('dry-run') && + (!$input->isInteractive() || $io->confirm(\sprintf( + 'Are you sure you want to delete %d custom-form answer(s) and %d document(s) from “%s” before %s', + $count, + $documentsCount, + $customForm->getName(), + $purgeBefore->format('Y-m-d H:i') + ), false)) + ) { + $this->managerRegistry + ->getRepository(CustomFormAnswer::class) + ->deleteByCustomFormSubmittedBefore($customForm, $purgeBefore); + + foreach ($documents as $document) { + $this->eventDispatcher->dispatch( + new DocumentDeletedEvent($document) + ); + if ($output->isVeryVerbose()) { + $io->info(\sprintf( + '“%s” document has been deleted', + $document->getRelativePath() + )); + } + $this->managerRegistry->getManager()->remove($document); + } + $this->managerRegistry->getManager()->flush(); + $this->logger->info(\sprintf( + '%d answer(s) and %d document(s) were deleted from “%s” custom-form before %s', + $count, + $documentsCount, + $customForm->getName(), + $purgeBefore->format('Y-m-d H:i') + )); + } + } + } + } + + return 0; + } +} diff --git a/lib/RoadizCoreBundle/src/Console/DecodePrivateKeyCommand.php b/lib/RoadizCoreBundle/src/Console/DecodePrivateKeyCommand.php new file mode 100644 index 00000000..e213f591 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Console/DecodePrivateKeyCommand.php @@ -0,0 +1,49 @@ +keyChain = $keyChain; + $this->uniqueKeyEncoderFactory = $uniqueKeyEncoderFactory; + } + + protected function configure(): void + { + $this->setName('crypto:private-key:decode') + ->addArgument('key-name', InputArgument::REQUIRED) + ->addArgument('data', InputArgument::REQUIRED) + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $keyName = $input->getArgument('key-name'); + $encoder = $this->uniqueKeyEncoderFactory->getEncoder($keyName); + $encoded = $encoder->decode($input->getArgument('data')); + + $io->note($encoded->getString()); + return 0; + } +} diff --git a/lib/RoadizCoreBundle/src/Console/EncodePrivateKeyCommand.php b/lib/RoadizCoreBundle/src/Console/EncodePrivateKeyCommand.php new file mode 100644 index 00000000..763b2ffa --- /dev/null +++ b/lib/RoadizCoreBundle/src/Console/EncodePrivateKeyCommand.php @@ -0,0 +1,50 @@ +keyChain = $keyChain; + $this->uniqueKeyEncoderFactory = $uniqueKeyEncoderFactory; + } + + protected function configure(): void + { + $this->setName('crypto:private-key:encode') + ->addArgument('key-name', InputArgument::REQUIRED) + ->addArgument('data', InputArgument::REQUIRED) + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $keyName = $input->getArgument('key-name'); + $encoder = $this->uniqueKeyEncoderFactory->getEncoder($keyName); + $encoded = $encoder->encode(new HiddenString($input->getArgument('data'))); + + $io->note($encoded); + return 0; + } +} diff --git a/lib/RoadizCoreBundle/src/Console/FilesCommandTrait.php b/lib/RoadizCoreBundle/src/Console/FilesCommandTrait.php new file mode 100644 index 00000000..e215fe70 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Console/FilesCommandTrait.php @@ -0,0 +1,32 @@ +fileAware = $fileAware; + $this->exportDir = $exportDir; + $this->appNamespace = $appNamespace; + } + + protected function configure(): void + { + $this + ->setName('files:export') + ->setDescription('Export public files, private files and fonts into a single ZIP archive at root dir.'); + } + + /** + * @param string $appName + * @return string + */ + protected function getArchiveFileName(string $appName = "files_export"): string + { + return $appName . '_' . date('Y-m-d') . '.zip'; + } + + /** + * @param InputInterface $input + * @param OutputInterface $output + * @return int + */ + protected function execute(InputInterface $input, OutputInterface $output): int + { + $fs = new Filesystem(); + + $publicFileFolder = $this->fileAware->getPublicFilesPath(); + $privateFileFolder = $this->fileAware->getPrivateFilesPath(); + $fontFileFolder = $this->fileAware->getFontsFilesPath(); + + $archiveName = $this->getArchiveFileName((new AsciiSlugger())->slug($this->appNamespace, '_')->toString()); + $archivePath = $this->exportDir . DIRECTORY_SEPARATOR . $archiveName; + + if (!$fs->exists($this->exportDir)) { + throw new \RuntimeException($archivePath . ': directory does not exist or is not writable'); + } + + $zip = new ZipArchive(); + $zip->open($archivePath, ZipArchive::CREATE | ZipArchive::OVERWRITE); + + if ($fs->exists($publicFileFolder)) { + $this->zipFolder($zip, $publicFileFolder, $this->getPublicFolderName()); + } + if ($fs->exists($privateFileFolder)) { + $this->zipFolder($zip, $privateFileFolder, $this->getPrivateFolderName()); + } + if ($fs->exists($fontFileFolder)) { + $this->zipFolder($zip, $fontFileFolder, $this->getFontsFolderName()); + } + + // Zip archive will be created only after closing object + $zip->close(); + return 0; + } + + + /** + * @param ZipArchive $zip + * @param string $folder + * @param string $prefix + */ + protected function zipFolder(ZipArchive $zip, string $folder, string $prefix = "/public"): void + { + $finder = new Finder(); + $files = $finder->files() + ->in($folder) + ->ignoreDotFiles(false) + ->exclude(['fonts', 'private']); + + /** + * @var SplFileInfo $file + */ + foreach ($files as $file) { + // Skip directories (they would be added automatically) + if (!$file->isDir()) { + // Get real and relative path for current file + $filePath = $file->getRealPath(); + $relativePath = substr($filePath, strlen($folder) + 1); + + // Add current file to archive + $zip->addFile($filePath, $prefix . '/' . $relativePath); + } + } + } +} diff --git a/lib/RoadizCoreBundle/src/Console/FilesImportCommand.php b/lib/RoadizCoreBundle/src/Console/FilesImportCommand.php new file mode 100644 index 00000000..283cadc3 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Console/FilesImportCommand.php @@ -0,0 +1,97 @@ +fileAware = $fileAware; + $this->exportDir = $exportDir; + $this->appNamespace = $appNamespace; + } + + protected function configure(): void + { + $this + ->setName('files:import') + ->setDescription('Import public files, private files and fonts from a single ZIP archive.') + ->setDefinition([ + new InputArgument('input', InputArgument::REQUIRED, 'ZIP file path to import.'), + ]); + } + + /** + * @param InputInterface $input + * @param OutputInterface $output + * @return int + */ + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $confirmation = new ConfirmationQuestion( + 'Are you sure to import files from this archive? Your existing files will be lost!', + false + ); + + $appNamespace = (new AsciiSlugger())->slug($this->appNamespace, '_'); + $tempDir = tempnam(sys_get_temp_dir(), $appNamespace . '_files'); + if (file_exists($tempDir)) { + unlink($tempDir); + } + mkdir($tempDir); + + $zipArchivePath = $input->getArgument('input'); + $zip = new ZipArchive(); + if (true === $zip->open($zipArchivePath)) { + if ( + $io->askQuestion( + $confirmation + ) + ) { + $zip->extractTo($tempDir); + + $fs = new Filesystem(); + if ($fs->exists($tempDir . $this->getPublicFolderName())) { + $fs->mirror($tempDir . $this->getPublicFolderName(), $this->fileAware->getPublicFilesPath()); + $io->success('Public files have been imported.'); + } + if ($fs->exists($tempDir . $this->getPrivateFolderName())) { + $fs->mirror($tempDir . $this->getPrivateFolderName(), $this->fileAware->getPrivateFilesPath()); + $io->success('Private files have been imported.'); + } + if ($fs->exists($tempDir . $this->getFontsFolderName())) { + $fs->mirror($tempDir . $this->getFontsFolderName(), $this->fileAware->getFontsFilesPath()); + $io->success('Font files have been imported.'); + } + + $fs->remove($tempDir); + } + return 0; + } else { + $io->error('Zip archive does not exist or is invalid.'); + return 1; + } + } +} diff --git a/lib/RoadizCoreBundle/src/Console/GenerateApiResourceCommand.php b/lib/RoadizCoreBundle/src/Console/GenerateApiResourceCommand.php new file mode 100644 index 00000000..7bfda603 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Console/GenerateApiResourceCommand.php @@ -0,0 +1,58 @@ +managerRegistry = $managerRegistry; + $this->apiResourceGenerator = $apiResourceGenerator; + } + + protected function configure(): void + { + $this->setName('generate:api-resources') + ->setDescription('Generate node-sources entities API Platform resource files.') + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + /** @var NodeType[] $nodeTypes */ + $nodeTypes = $this->managerRegistry + ->getRepository(NodeType::class) + ->findAll(); + + if (count($nodeTypes) > 0) { + foreach ($nodeTypes as $nt) { + $resourcePath = $this->apiResourceGenerator->generate($nt); + if (null !== $resourcePath) { + $io->writeln("* API resource " . $resourcePath . " has been generated."); + } + } + return 0; + } else { + $io->error('No available node-types…'); + return 1; + } + } +} diff --git a/lib/RoadizCoreBundle/src/Console/GenerateNodeSourceEntitiesCommand.php b/lib/RoadizCoreBundle/src/Console/GenerateNodeSourceEntitiesCommand.php new file mode 100644 index 00000000..1a630456 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Console/GenerateNodeSourceEntitiesCommand.php @@ -0,0 +1,68 @@ +managerRegistry = $managerRegistry; + $this->handlerFactory = $handlerFactory; + } + + protected function configure(): void + { + $this->setName('generate:nsentities') + ->setDescription('Generate node-sources entities PHP classes.'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $nodetypes = $this->managerRegistry + ->getRepository(NodeType::class) + ->findAll(); + + if (count($nodetypes) > 0) { + /** @var NodeType $nt */ + foreach ($nodetypes as $nt) { + /** @var NodeTypeHandler $handler */ + $handler = $this->handlerFactory->getHandler($nt); + $handler->removeSourceEntityClass(); + $handler->generateSourceEntityClass(); + $io->writeln("* Source class " . $nt->getSourceEntityClassName() . " has been generated."); + + if ($output->isVeryVerbose()) { + $io->writeln("\t" . $handler->getSourceClassPath() . ""); + } + } + return 0; + } else { + $io->error('No available node-types…'); + return 1; + } + } +} diff --git a/lib/RoadizCoreBundle/src/Console/GeneratePrivateKeyCommand.php b/lib/RoadizCoreBundle/src/Console/GeneratePrivateKeyCommand.php new file mode 100644 index 00000000..3f46c280 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Console/GeneratePrivateKeyCommand.php @@ -0,0 +1,47 @@ +keyChain = $keyChain; + $this->privateKeyName = $privateKeyName; + } + + protected function configure(): void + { + $this->setName('crypto:private-key:generate') + ->setDescription('Generate a default private key to encode data in your database.') + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $this->keyChain->generate($this->privateKeyName); + $io->success(sprintf('Private key has been generated: %s', $this->privateKeyName)); + return 0; + } +} diff --git a/lib/RoadizCoreBundle/src/Console/InstallCommand.php b/lib/RoadizCoreBundle/src/Console/InstallCommand.php new file mode 100644 index 00000000..0e21d59e --- /dev/null +++ b/lib/RoadizCoreBundle/src/Console/InstallCommand.php @@ -0,0 +1,136 @@ +managerRegistry = $managerRegistry; + $this->rolesImporter = $rolesImporter; + $this->groupsImporter = $groupsImporter; + $this->settingsImporter = $settingsImporter; + } + + protected function configure(): void + { + $this + ->setName('install') + ->setDescription('Install Roadiz roles, settings, translations and default backend theme'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $io->note('Before installing Roadiz, did you create database schema? ' . PHP_EOL . + 'If not execute: bin/console doctrine:migrations:migrate'); + $question = new ConfirmationQuestion( + 'Are you sure to perform installation?', + false + ); + + if ( + $input->getOption('no-interaction') || + $io->askQuestion($question) + ) { + $fixturesRoot = dirname(__DIR__) . '/../config'; + $data = Yaml::parse(file_get_contents($fixturesRoot . "/fixtures.yaml")); + + if (isset($data["importFiles"]['roles'])) { + foreach ($data["importFiles"]['roles'] as $filename) { + $filePath = $fixturesRoot . "/" . $filename; + $this->rolesImporter->import(file_get_contents($filePath)); + $io->success('Theme file “' . $filePath . '” has been imported.'); + } + } + if (isset($data["importFiles"]['groups'])) { + foreach ($data["importFiles"]['groups'] as $filename) { + $filePath = $fixturesRoot . "/" . $filename; + $this->groupsImporter->import(file_get_contents($filePath)); + $io->success('Theme file “' . $filePath . '” has been imported.'); + } + } + if (isset($data["importFiles"]['settings'])) { + foreach ($data["importFiles"]['settings'] as $filename) { + $filePath = $fixturesRoot . "/" . $filename; + $this->settingsImporter->import(file_get_contents($filePath)); + $io->success('Theme files “' . $filePath . '” has been imported.'); + } + } + $manager = $this->managerRegistry->getManagerForClass(Translation::class); + /* + * Create default translation + */ + if (!$this->hasDefaultTranslation()) { + $defaultTrans = new Translation(); + $defaultTrans + ->setDefaultTranslation(true) + ->setLocale("en") + ->setName("Default translation"); + + $manager->persist($defaultTrans); + + $io->success('Default translation installed.'); + } else { + $io->warning('A default translation is already installed.'); + } + $manager->flush(); + + if ($manager instanceof EntityManagerInterface) { + // Clear result cache + $cacheDriver = $manager->getConfiguration()->getResultCacheImpl(); + if ($cacheDriver instanceof CacheProvider) { + $cacheDriver->deleteAll(); + } + } + } + return 0; + } + + /** + * Tell if there is any translation. + * + * @return boolean + */ + public function hasDefaultTranslation(): bool + { + $default = $this->managerRegistry->getRepository(Translation::class)->findOneBy([]); + return $default !== null; + } +} diff --git a/lib/RoadizCoreBundle/src/Console/LogsCleanupCommand.php b/lib/RoadizCoreBundle/src/Console/LogsCleanupCommand.php new file mode 100644 index 00000000..6e00af9a --- /dev/null +++ b/lib/RoadizCoreBundle/src/Console/LogsCleanupCommand.php @@ -0,0 +1,84 @@ +managerRegistry = $managerRegistry; + } + + protected function configure(): void + { + $this + ->setName('logs:cleanup') + ->setDescription('Clean up logs entries older than 6 months from database.') + ->addOption('erase', null, InputOption::VALUE_NONE, 'Actually delete outdated log entries.') + ->addOption('since', null, InputOption::VALUE_REQUIRED, 'Change default deletion duration from now.') + ; + } + + /** + * @inheritDoc + */ + protected function execute(InputInterface $input, OutputInterface $output): int + { + $now = new \DateTime('now'); + $since = '-3 months'; + if (\is_string($input->getOption('since'))) { + $since = '-' . $input->getOption('since'); + } + $now->add(\DateInterval::createFromDateString($since)); + $io = new SymfonyStyle($input, $output); + + /** @var LogRepository $logRepository */ + $logRepository = $this->managerRegistry->getRepository(Log::class); + $qb = $logRepository->createQueryBuilder('l'); + $qb->select($qb->expr()->count('l')) + ->andWhere($qb->expr()->lte('l.datetime', ':date')) + ->setParameter(':date', $now) + ; + + try { + $logs = $qb->getQuery()->getSingleScalarResult(); + } catch (NoResultException $e) { + $logs = 0; + } + + $io->note($logs . ' log entries found before ' . $now->format('Y-m-d H:i:s') . '.'); + + if ($input->getOption('erase') && $logs > 0) { + $qb2 = $logRepository->createQueryBuilder('l'); + $qb2->delete() + ->andWhere($qb->expr()->lte('l.datetime', ':date')) + ->setParameter(':date', $now) + ; + try { + $numDeleted = $qb2->getQuery()->execute(); + $io->success($numDeleted . ' log entries were deleted.'); + } catch (NoResultException $e) { + $io->writeln('No log entries were deleted.'); + } + } + return 0; + } +} diff --git a/lib/RoadizCoreBundle/src/Console/MailerTestCommand.php b/lib/RoadizCoreBundle/src/Console/MailerTestCommand.php new file mode 100644 index 00000000..f7c43949 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Console/MailerTestCommand.php @@ -0,0 +1,61 @@ +emailManager = $emailManager; + } + + + protected function configure(): void + { + $this->setName('mailer:send:test') + ->addArgument('email', InputArgument::REQUIRED, 'Receiver email address.') + ->addOption('from', 'f', InputOption::VALUE_REQUIRED, 'Sender envelop email address.') + ->setDescription('Send a test email.') + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $title = '[test] Roadiz test email'; + $to = Address::create($input->getArgument('email')); + $from = Address::create($input->getOption('from') ?? 'test@roadiz.io'); + + $this->emailManager + ->setReceiver($to) + ->setSender($from) + // Uses email_sender customizable setting + ->setSubject($title) + ->setEmailPlainTextTemplate('@RoadizCore/email/base_email.txt.twig') + ->setEmailTemplate('@RoadizCore/email/base_email.html.twig') + ->setAssignation([ + 'title' => $title, + 'content' => 'This is a test email send to *' . $to->getAddress() . '* from `mailer:send:test` CLI command.', + 'mailContact' => $from->getAddress() + ]) + ->send(); + (new SymfonyStyle($input, $output))->success('Email sent.'); + return 0; + } +} diff --git a/lib/RoadizCoreBundle/src/Console/NodeApplyUniversalFieldsCommand.php b/lib/RoadizCoreBundle/src/Console/NodeApplyUniversalFieldsCommand.php new file mode 100644 index 00000000..a136ac45 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Console/NodeApplyUniversalFieldsCommand.php @@ -0,0 +1,89 @@ +managerRegistry = $managerRegistry; + $this->universalDataDuplicator = $universalDataDuplicator; + } + + protected function configure(): void + { + $this->setName('nodes:force-universal') + ->setDescription('Clean every nodes universal fields getting value form their default translation.') + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $translation = $this->managerRegistry->getRepository(Translation::class)->findDefault(); + $io = new SymfonyStyle($input, $output); + + $manager = $this->managerRegistry->getManagerForClass(NodesSources::class); + if (null === $manager) { + throw new \RuntimeException('No manager found for ' . NodesSources::class); + } + + $qb = $manager->createQueryBuilder(); + $qb->select('ns') + ->distinct(true) + ->from(NodesSources::class, 'ns') + ->innerJoin('ns.node', 'n') + ->innerJoin('n.nodeType', 'nt') + ->innerJoin('nt.fields', 'ntf') + ->andWhere($qb->expr()->eq('ns.translation', ':translation')) + ->andWhere($qb->expr()->eq('ntf.universal', true)) + ->setParameter(':translation', $translation); + try { + $sources = $qb->getQuery()->getResult(); + $io->note(count($sources) . ' node(s) with universal fields were found.'); + + $question = new ConfirmationQuestion( + 'Are you sure to force every universal fields?', + false + ); + if ( + $io->askQuestion( + $question + ) + ) { + $io->progressStart(count($sources)); + + /** @var NodesSources $source */ + foreach ($sources as $source) { + $this->universalDataDuplicator->duplicateUniversalContents($source); + $io->progressAdvance(); + } + $manager->flush(); + $io->progressFinish(); + } + } catch (NoResultException $e) { + $io->warning('No node with universal fields were found.'); + } + return 0; + } +} diff --git a/lib/RoadizCoreBundle/src/Console/NodeClearTagCommand.php b/lib/RoadizCoreBundle/src/Console/NodeClearTagCommand.php new file mode 100644 index 00000000..7ba65879 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Console/NodeClearTagCommand.php @@ -0,0 +1,103 @@ +managerRegistry = $managerRegistry; + } + + protected function configure(): void + { + $this->setName('nodes:clear-tag') + ->addArgument('tagId', InputArgument::REQUIRED, 'Tag ID to delete nodes from.') + ->setDescription('Delete every Nodes linked with a given Tag. Danger zone') + ; + } + + protected function getNodeQueryBuilder(Tag $tag): QueryBuilder + { + $qb = $this->managerRegistry->getRepository(Node::class)->createQueryBuilder('n'); + return $qb->innerJoin('n.nodesTags', 'ntg') + ->andWhere($qb->expr()->eq('ntg.tag', ':tagId')) + ->setParameter(':tagId', $tag); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $em = $this->managerRegistry->getManagerForClass(Node::class); + $this->io = new SymfonyStyle($input, $output); + + $tagId = (int) $input->getArgument('tagId'); + if ($tagId <= 0) { + throw new \InvalidArgumentException('Tag ID must be a valid ID'); + } + /** @var Tag|null $tag */ + $tag = $em->find(Tag::class, $tagId); + if ($tag === null) { + throw new \InvalidArgumentException(sprintf('Tag #%d does not exist.', $tagId)); + } + + $batchSize = 20; + $i = 0; + + $count = (int) $this->getNodeQueryBuilder($tag) + ->select('count(n)') + ->getQuery() + ->getSingleScalarResult(); + + if ($count <= 0) { + $this->io->warning('No nodes were found linked with this tag.'); + return 0; + } + + if ( + $this->io->askQuestion(new ConfirmationQuestion( + sprintf('Are you sure to delete permanently %d nodes?', $count), + false + )) + ) { + $results = $this->getNodeQueryBuilder($tag) + ->select('n') + ->getQuery() + ->getResult(); + + $this->io->progressStart($count); + /** @var Node $node */ + foreach ($results as $node) { + $em->remove($node); + if (($i % $batchSize) === 0) { + $em->flush(); // Executes all updates. + } + ++$i; + $this->io->progressAdvance(); + } + $em->flush(); + $this->io->progressFinish(); + } + + return 0; + } +} diff --git a/lib/RoadizCoreBundle/src/Console/NodeTypesAddFieldCommand.php b/lib/RoadizCoreBundle/src/Console/NodeTypesAddFieldCommand.php new file mode 100644 index 00000000..e8651e30 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Console/NodeTypesAddFieldCommand.php @@ -0,0 +1,60 @@ +setName('nodetypes:add-fields') + ->setDescription('Add fields to a node-type') + ->addArgument( + 'name', + InputArgument::REQUIRED, + 'Node-type name' + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $name = $input->getArgument('name'); + + /** @var NodeType|null $nodeType */ + $nodeType = $this->managerRegistry + ->getRepository(NodeType::class) + ->findOneBy(['name' => $name]); + + if ($nodeType !== null) { + $latestPosition = $this->managerRegistry + ->getRepository(NodeTypeField::class) + ->findLatestPositionInNodeType($nodeType); + $this->addNodeTypeField($nodeType, $latestPosition + 1, $io); + $this->managerRegistry->getManagerForClass(NodeTypeField::class)->flush(); + + /** @var NodeTypeHandler $handler */ + $handler = $this->handlerFactory->getHandler($nodeType); + $handler->regenerateEntityClass(); + $this->schemaUpdater->updateNodeTypesSchema(); + + $io->success('Node type ' . $nodeType->getName() . ' has been updated.'); + return 0; + } else { + $io->error('Node-type "' . $name . '" does not exist.'); + return 1; + } + } +} diff --git a/lib/RoadizCoreBundle/src/Console/NodeTypesCommand.php b/lib/RoadizCoreBundle/src/Console/NodeTypesCommand.php new file mode 100644 index 00000000..23107664 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Console/NodeTypesCommand.php @@ -0,0 +1,100 @@ +managerRegistry = $managerRegistry; + } + + protected function configure(): void + { + $this->setName('nodetypes:list') + ->setDescription('List available node-types or fields for a given node-type name') + ->addArgument( + 'name', + InputArgument::OPTIONAL, + 'Node-type name' + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $name = $input->getArgument('name'); + + if ($name) { + $nodetype = $this->managerRegistry + ->getRepository(NodeType::class) + ->findOneByName($name); + + if ($nodetype !== null) { + /** @var array $fields */ + $fields = $this->managerRegistry->getRepository(NodeTypeField::class) + ->findBy([ + 'nodeType' => $nodetype, + ], ['position' => 'ASC']); + + $tableContent = []; + foreach ($fields as $field) { + $tableContent[] = [ + $field->getId(), + $field->getLabel(), + $field->getName(), + str_replace('.type', '', $field->getTypeName()), + ($field->isVisible() ? 'X' : ''), + ($field->isIndexed() ? 'X' : ''), + ]; + } + $io->table(['Id', 'Label', 'Name', 'Type', 'Visible', 'Index'], $tableContent); + } else { + $io->note($name . ' node type does not exist.'); + return 0; + } + } else { + /** @var array $nodetypes */ + $nodetypes = $this->managerRegistry + ->getRepository(NodeType::class) + ->findBy([], ['name' => 'ASC']); + + if (count($nodetypes) > 0) { + $tableContent = []; + + foreach ($nodetypes as $nt) { + $tableContent[] = [ + $nt->getId(), + $nt->getName(), + ($nt->isVisible() ? 'X' : ''), + ]; + } + + $io->table(['Id', 'Title', 'Visible'], $tableContent); + } else { + $io->note('No available node-types…'); + } + } + return 0; + } +} diff --git a/lib/RoadizCoreBundle/src/Console/NodeTypesCreationCommand.php b/lib/RoadizCoreBundle/src/Console/NodeTypesCreationCommand.php new file mode 100644 index 00000000..81c11ba2 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Console/NodeTypesCreationCommand.php @@ -0,0 +1,164 @@ +managerRegistry = $managerRegistry; + $this->handlerFactory = $handlerFactory; + $this->schemaUpdater = $schemaUpdater; + } + + protected function configure(): void + { + $this->setName('nodetypes:create') + ->setDescription('Manage node-types') + ->addArgument( + 'name', + InputArgument::REQUIRED, + 'Node-type name' + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $name = $input->getArgument('name'); + + if (empty($name)) { + throw new \InvalidArgumentException('Name must not be empty.'); + } + + /** @var NodeType|null $nodeType */ + $nodeType = $this->managerRegistry + ->getRepository(NodeType::class) + ->findOneBy(['name' => $name]); + + if ($nodeType !== null) { + $io->error('Node-type "' . $name . '" already exists.'); + return 1; + } else { + $this->executeCreation($input, $output); + } + return 0; + } + + private function executeCreation(InputInterface $input, OutputInterface $output): void + { + $io = new SymfonyStyle($input, $output); + $name = $input->getArgument('name'); + $nt = new NodeType(); + $nt->setName($name); + + $io->note('OK! Let’s create that "' . $nt->getName() . '" node-type together!'); + + $question0 = new Question('Enter your node-type display name', ucwords($name)); + $displayName = $io->askQuestion($question0); + $nt->setDisplayName($displayName); + + $question1 = new Question('Enter your node-type description', ucwords($name)); + $description = $io->askQuestion($question1); + $nt->setDescription($description); + $this->managerRegistry->getManagerForClass(NodeType::class)->persist($nt); + + // Begin nt-field creation loop + $this->addNodeTypeField($nt, 1, $io); + + $this->managerRegistry->getManagerForClass(NodeType::class)->flush(); + + /** @var NodeTypeHandler $handler */ + $handler = $this->handlerFactory->getHandler($nt); + $handler->regenerateEntityClass(); + $this->schemaUpdater->updateNodeTypesSchema(); + + $io->success('Node type ' . $nt->getName() . ' has been created.'); + } + + protected function addNodeTypeField(NodeType $nodeType, int|float|string $position, SymfonyStyle $io): void + { + $field = new NodeTypeField(); + $field->setPosition((float) $position); + + $questionfName = new Question('[Field ' . $position . '] Enter field name', 'content'); + $fName = $io->askQuestion($questionfName); + $field->setName($fName); + + $questionfLabel = new Question('[Field ' . $position . '] Enter field label', 'Your content'); + $fLabel = $io->askQuestion($questionfLabel); + $field->setLabel($fLabel); + + $questionfType = new Question('[Field ' . $position . '] Enter field type', 'STRING_T'); + $questionfType->setAutocompleterValues([ + 'STRING_T', + 'DATETIME_T', + 'DATE_T', + 'TEXT_T', + 'MARKDOWN_T', + 'BOOLEAN_T', + 'INTEGER_T', + 'DECIMAL_T', + 'EMAIL_T', + 'ENUM_T', + 'MULTIPLE_T', + 'DOCUMENTS_T', + 'NODES_T', + 'CHILDREN_T', + 'COLOUR_T', + 'GEOTAG_T', + 'CUSTOM_FORMS_T', + 'MULTI_GEOTAG_T', + 'JSON_T', + 'CSS_T', + ]); + + $fType = $io->askQuestion($questionfType); + $fType = constant(NodeTypeField::class . '::' . $fType); + $field->setType($fType); + + $questionIndexed = new ConfirmationQuestion('[Field ' . $position . '] Must this field be indexed?', false); + if ($io->askQuestion($questionIndexed)) { + $field->setIndexed(true); + } + + // Need to populate each side + $nodeType->getFields()->add($field); + $this->managerRegistry->getManagerForClass(NodeType::class)->persist($field); + $field->setNodeType($nodeType); + + $questionAdd = new ConfirmationQuestion('Do you want to add another field?', true); + if ($io->askQuestion($questionAdd)) { + $this->addNodeTypeField($nodeType, $position + 1, $io); + } + } +} diff --git a/lib/RoadizCoreBundle/src/Console/NodeTypesDeleteCommand.php b/lib/RoadizCoreBundle/src/Console/NodeTypesDeleteCommand.php new file mode 100644 index 00000000..e4bfb545 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Console/NodeTypesDeleteCommand.php @@ -0,0 +1,95 @@ +managerRegistry = $managerRegistry; + $this->handlerFactory = $handlerFactory; + $this->schemaUpdater = $schemaUpdater; + } + + protected function configure(): void + { + $this->setName('nodetypes:delete') + ->setDescription('Delete a node-type') + ->addArgument( + 'name', + InputArgument::REQUIRED, + 'Node-type name' + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $name = $input->getArgument('name'); + + if (empty($name)) { + throw new \InvalidArgumentException('Name must not be empty.'); + } + + /** @var NodeType|null $nodeType */ + $nodeType = $this->managerRegistry + ->getRepository(NodeType::class) + ->findOneByName($name); + + if ($nodeType !== null) { + $io->note('///////////////////////////////' . PHP_EOL . + '/////////// WARNING ///////////' . PHP_EOL . + '///////////////////////////////' . PHP_EOL . + 'This operation cannot be undone.' . PHP_EOL . + 'Deleting a node-type, you will automatically delete every nodes of this type.'); + $question = new ConfirmationQuestion( + 'Are you sure to delete ' . $nodeType->getName() . ' node-type?', + false + ); + if ( + $io->askQuestion( + $question + ) + ) { + /** @var NodeTypeHandler $handler */ + $handler = $this->handlerFactory->getHandler($nodeType); + $handler->removeSourceEntityClass(); + $this->managerRegistry->getManagerForClass(NodeType::class)->remove($nodeType); + $this->managerRegistry->getManagerForClass(NodeType::class)->flush(); + $this->schemaUpdater->updateNodeTypesSchema(); + $io->success('Node-type deleted.'); + } + } else { + $io->error('"' . $name . '" node type does not exist'); + return 1; + } + return 0; + } +} diff --git a/lib/RoadizCoreBundle/src/Console/NodesCleanNamesCommand.php b/lib/RoadizCoreBundle/src/Console/NodesCleanNamesCommand.php new file mode 100644 index 00000000..bee548c7 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Console/NodesCleanNamesCommand.php @@ -0,0 +1,158 @@ +managerRegistry = $managerRegistry; + } + + protected function configure(): void + { + $this->setName('nodes:clean-names') + ->setDescription('Clean every nodes names according to their default node-source title.') + ->addOption( + 'use-date', + null, + InputOption::VALUE_NONE, + 'Use date instead of uniqid.' + ) + ->addOption( + 'dry-run', + 'd', + InputOption::VALUE_NONE, + 'Do nothing, only print information.' + ) + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $entityManager = $this->managerRegistry->getManagerForClass(Node::class); + $io = new SymfonyStyle($input, $output); + + $translation = $entityManager + ->getRepository(Translation::class) + ->findDefault(); + + if (null !== $translation) { + /** @phpstan-ignore-next-line */ + $nodes = $entityManager + ->getRepository(Node::class) + ->setDisplayingNotPublishedNodes(true) + ->findBy([ + 'dynamicNodeName' => true, + 'locked' => false, + 'translation' => $translation, + ]); + + $io->note( + 'This command will rename EVERY nodes (except for locked and not dynamic ones) names according to their node-source for current default translation.' . PHP_EOL . + count($nodes) . ' nodes might be affected.' + ); + + $question1 = new ConfirmationQuestion('Are you sure to proceed? This could break many page URLs!', false); + + if ($io->askQuestion($question1)) { + $io->note('Renaming ' . count($nodes) . ' nodes…'); + $renameCount = 0; + $names = []; + + /** @var Node $node */ + foreach ($nodes as $node) { + $nodeSource = $node->getNodeSources()->first() ?: null; + if ($nodeSource !== null) { + $prefixName = $nodeSource->getTitle() != "" ? + $nodeSource->getTitle() : + $node->getNodeName(); + + $prefixNameSlug = $this->nodeNamePolicy->getCanonicalNodeName($nodeSource); + /* + * Proceed to rename only if best name is not the current + * node-name AND if it is not ALREADY suffixed with a unique ID. + */ + if ( + $prefixNameSlug != $node->getNodeName() && + $this->nodeNamePolicy->isNodeNameValid($prefixNameSlug) && + !$this->nodeNamePolicy->isNodeNameWithUniqId($prefixNameSlug, $nodeSource->getNode()->getNodeName()) + ) { + $alreadyUsed = $this->nodeNamePolicy->isNodeNameAlreadyUsed($prefixNameSlug); + if (!$alreadyUsed) { + $names[] = [ + $node->getNodeName(), + $prefixNameSlug + ]; + $node->setNodeName($prefixNameSlug); + } else { + if ( + $input->getOption('use-date') && + null !== $nodeSource->getPublishedAt() + ) { + $suffixedNameSlug = $this->nodeNamePolicy->getDatestampedNodeName($nodeSource); + } else { + $suffixedNameSlug = $this->nodeNamePolicy->getSafeNodeName($nodeSource); + } + if (!$this->nodeNamePolicy->isNodeNameAlreadyUsed($suffixedNameSlug)) { + $names[] = [ + $node->getNodeName(), + $suffixedNameSlug + ]; + $node->setNodeName($suffixedNameSlug); + } else { + $suffixedNameSlug = $this->nodeNamePolicy->getSafeNodeName($nodeSource); + $names[] = [ + $node->getNodeName(), + $suffixedNameSlug + ]; + $node->setNodeName($suffixedNameSlug); + } + } + if (!$input->getOption('dry-run')) { + $entityManager->flush(); + } + $renameCount++; + } + } + } + + $io->table(['Old name', 'New name'], $names); + + if (!$input->getOption('dry-run')) { + $io->success('Renaming done! ' . $renameCount . ' nodes have been affected. Do not forget to reindex your Solr documents if you are using it.'); + } else { + $io->success($renameCount . ' nodes would have been affected. Nothing was saved to database.'); + } + } else { + $io->warning('Renaming cancelled…'); + return 1; + } + } + + return 0; + } +} diff --git a/lib/RoadizCoreBundle/src/Console/NodesCommand.php b/lib/RoadizCoreBundle/src/Console/NodesCommand.php new file mode 100644 index 00000000..3442ea0c --- /dev/null +++ b/lib/RoadizCoreBundle/src/Console/NodesCommand.php @@ -0,0 +1,81 @@ +managerRegistry = $managerRegistry; + } + + protected function configure(): void + { + $this->setName('nodes:list') + ->setDescription('List available nodes') + ->addOption( + 'type', + 't', + InputOption::VALUE_REQUIRED, + 'Filter by node-type name' + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $nodes = []; + $tableContent = []; + + if ($input->getOption('type')) { + $nodeType = $this->managerRegistry + ->getRepository(NodeType::class) + ->findByName($input->getOption('type')); + if (null !== $nodeType) { + $nodes = $this->managerRegistry + ->getRepository(Node::class) + ->setDisplayingNotPublishedNodes(true) + ->findBy(['nodeType' => $nodeType], ['nodeName' => 'ASC']); + } + } else { + $nodes = $this->managerRegistry + ->getRepository(Node::class) + ->setDisplayingNotPublishedNodes(true) + ->findBy([], ['nodeName' => 'ASC']); + } + + /** @var Node $node */ + foreach ($nodes as $node) { + $tableContent[] = [ + $node->getId(), + $node->getNodeName(), + $node->getNodeType()->getName(), + (!$node->isVisible() ? 'X' : ''), + ($node->isPublished() ? 'X' : ''), + ]; + } + + $io->table(['Id', 'Name', 'Type', 'Hidden', 'Published'], $tableContent); + return 0; + } +} diff --git a/lib/RoadizCoreBundle/src/Console/NodesCreationCommand.php b/lib/RoadizCoreBundle/src/Console/NodesCreationCommand.php new file mode 100644 index 00000000..98e3ce6b --- /dev/null +++ b/lib/RoadizCoreBundle/src/Console/NodesCreationCommand.php @@ -0,0 +1,133 @@ +managerRegistry = $managerRegistry; + $this->nodeFactory = $nodeFactory; + } + + protected function configure(): void + { + $this->setName('nodes:create') + ->setDescription('Create a new node') + ->addArgument( + 'node-name', + InputArgument::REQUIRED, + 'Node name' + ) + ->addArgument( + 'node-type', + InputArgument::REQUIRED, + 'Node-type name' + ) + ->addArgument( + 'locale', + InputArgument::OPTIONAL, + 'Translation locale' + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $nodeName = $input->getArgument('node-name'); + $typeName = $input->getArgument('node-type'); + $locale = $input->getArgument('locale'); + $this->io = new SymfonyStyle($input, $output); + + $existingNode = $this->managerRegistry + ->getRepository(Node::class) + ->setDisplayingNotPublishedNodes(true) + ->findOneByNodeName($nodeName); + + if (null === $existingNode) { + $type = $this->managerRegistry + ->getRepository(NodeType::class) + ->findOneByName($typeName); + + if (null !== $type) { + $translation = null; + + if ($locale) { + $translation = $this->managerRegistry + ->getRepository(TranslationInterface::class) + ->findOneBy(['locale' => $locale]); + } + + if ($translation === null) { + $translation = $this->managerRegistry + ->getRepository(TranslationInterface::class) + ->findDefault(); + } + + $this->executeNodeCreation($input->getArgument('node-name'), $type, $translation); + } else { + $this->io->error('"' . $typeName . '" node type does not exist.'); + return 1; + } + return 0; + } else { + $this->io->error($existingNode->getNodeName() . ' node already exists.'); + return 1; + } + } + + /** + * @param string $nodeName + * @param NodeType $type + * @param TranslationInterface $translation + */ + private function executeNodeCreation( + string $nodeName, + NodeType $type, + TranslationInterface $translation + ): void { + $node = $this->nodeFactory->create($nodeName, $type, $translation); + $source = $node->getNodeSources()->first() ?: null; + if (null === $source) { + throw new \InvalidArgumentException('Node source is null'); + } + $fields = $type->getFields(); + + foreach ($fields as $field) { + if (!$field->isVirtual()) { + $question = new Question('[Field ' . $field->getLabel() . '] : ', null); + $fValue = $this->io->askQuestion($question); + $setterName = $field->getSetterName(); + $source->$setterName($fValue); + } + } + + $this->managerRegistry->getManagerForClass(Node::class)->flush(); + $this->io->success('Node “' . $nodeName . '” created at root level.'); + } +} diff --git a/lib/RoadizCoreBundle/src/Console/NodesDetailsCommand.php b/lib/RoadizCoreBundle/src/Console/NodesDetailsCommand.php new file mode 100644 index 00000000..d5095807 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Console/NodesDetailsCommand.php @@ -0,0 +1,89 @@ +managerRegistry = $managerRegistry; + } + + protected function configure(): void + { + $this->setName('nodes:show') + ->setDescription('Show node details and data.') + ->addArgument('nodeName', InputArgument::REQUIRED, 'Node name to show') + ->addArgument('locale', InputArgument::REQUIRED, 'Translation locale to use') + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $translation = $this->managerRegistry->getRepository(Translation::class) + ->findOneBy(['locale' => $input->getArgument('locale')]); + + /** + * @var NodesSources|null $source + * @phpstan-ignore-next-line + */ + $source = $this->managerRegistry->getRepository(NodesSources::class) + ->setDisplayingNotPublishedNodes(true) + ->findOneBy([ + 'node.nodeName' => $input->getArgument('nodeName'), + 'translation' => $translation, + ]); + if (null !== $source) { + $io->title(get_class($source)); + $io->title('Title'); + $io->text($source->getTitle()); + + /** @var NodeTypeField $field */ + foreach ($source->getNode()->getNodeType()->getFields() as $field) { + if (!$field->isVirtual()) { + $getter = $field->getGetterName(); + $data = $source->$getter(); + + if (is_array($data)) { + $data = implode(', ', $data); + } + if ($data instanceof \DateTime) { + $data = $data->format('c'); + } + if ($data instanceof \stdClass) { + $data = \json_encode($data); + } + + if (!empty($data)) { + $io->title($field->getLabel()); + $io->text($data); + } + } + } + } else { + $io->error('No node found.'); + return 1; + } + return 0; + } +} diff --git a/lib/RoadizCoreBundle/src/Console/NodesEmptyTrashCommand.php b/lib/RoadizCoreBundle/src/Console/NodesEmptyTrashCommand.php new file mode 100644 index 00000000..d9078595 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Console/NodesEmptyTrashCommand.php @@ -0,0 +1,100 @@ +managerRegistry = $managerRegistry; + $this->handlerFactory = $handlerFactory; + } + + protected function configure(): void + { + $this + ->setName('nodes:empty-trash') + ->setDescription('Remove definitely deleted nodes.') + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $em = $this->managerRegistry->getManagerForClass(Node::class); + $countQb = $this->createNodeQueryBuilder(); + $countQuery = $countQb->select($countQb->expr()->count('n')) + ->andWhere($countQb->expr()->eq('n.status', Node::DELETED)) + ->getQuery(); + $emptiedCount = $countQuery->getSingleScalarResult(); + if ($emptiedCount > 0) { + $confirmation = new ConfirmationQuestion( + sprintf('Are you sure to empty nodes trashcan, %d nodes will be lost forever? [y/N]: ', $emptiedCount), + false + ); + if ($io->askQuestion($confirmation) || !$input->isInteractive()) { + $i = 0; + $batchSize = 100; + $io->progressStart((int) $emptiedCount); + + $qb = $this->createNodeQueryBuilder(); + $q = $qb->select('n') + ->andWhere($countQb->expr()->eq('n.status', Node::DELETED)) + ->getQuery(); + + foreach ($q->toIterable() as $row) { + /** @var NodeHandler $nodeHandler */ + $nodeHandler = $this->handlerFactory->getHandler($row); + $nodeHandler->removeWithChildrenAndAssociations(); + $io->progressAdvance(); + ++$i; + // Call flush time to times + if (($i % $batchSize) === 0) { + $em->flush(); + $em->clear(); + } + } + + /* + * Final flush + */ + $em->flush(); + $io->progressFinish(); + $io->success('Nodes trashcan has been emptied.'); + } + } else { + $io->success('Nodes trashcan is already empty.'); + } + + return 0; + } + + protected function createNodeQueryBuilder(): QueryBuilder + { + return $this->managerRegistry + ->getRepository(Node::class) + ->createQueryBuilder('n'); + } +} diff --git a/lib/RoadizCoreBundle/src/Console/NodesOrphansCommand.php b/lib/RoadizCoreBundle/src/Console/NodesOrphansCommand.php new file mode 100644 index 00000000..60fcf003 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Console/NodesOrphansCommand.php @@ -0,0 +1,102 @@ +managerRegistry = $managerRegistry; + } + + protected function configure(): void + { + $this->setName('nodes:orphans') + ->setDescription('Find nodes without any source attached, and delete them.') + ->addOption( + 'delete', + 'd', + InputOption::VALUE_NONE, + 'Delete orphans nodes.' + ); + } + + /** + * @param InputInterface $input + * @param OutputInterface $output + * + * @return int + * @throws \Doctrine\ORM\ORMException + * @throws \Doctrine\ORM\OptimisticLockException + */ + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $entityManager = $this->managerRegistry->getManagerForClass(Node::class); + $qb = $entityManager->createQueryBuilder(); + $qb->select('n') + ->from(Node::class, 'n') + ->leftJoin('n.nodeSources', 'ns') + ->having('COUNT(ns.id) = 0') + ->groupBy('n'); + + $orphans = []; + try { + $orphans = $qb->getQuery()->getResult(); + } catch (NoResultException $e) { + } + + if (count($orphans) > 0) { + $io->note(sprintf('You have %s orphan node(s)!', count($orphans))); + $tableContent = []; + + /** @var Node $node */ + foreach ($orphans as $node) { + $tableContent[] = [ + $node->getId(), + $node->getNodeName(), + null !== $node->getNodeType() ? $node->getNodeType()->getName() : '', + (!$node->isVisible() ? 'X' : ''), + ($node->isPublished() ? 'X' : ''), + ]; + } + + $io->table(['Id', 'Name', 'Type', 'Hidden', 'Published'], $tableContent); + + if ($input->getOption('delete')) { + /** @var Node $orphan */ + foreach ($orphans as $orphan) { + $entityManager->remove($orphan); + } + $entityManager->flush(); + + $io->success('Orphan nodes have been removed from your database.'); + } else { + $io->note('Use --delete option to actually remove these nodes.'); + } + } else { + $io->success('That’s OK, you don’t have any orphan node.'); + } + return 0; + } +} diff --git a/lib/RoadizCoreBundle/src/Console/PrivateKeyCommand.php b/lib/RoadizCoreBundle/src/Console/PrivateKeyCommand.php new file mode 100644 index 00000000..9e368498 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Console/PrivateKeyCommand.php @@ -0,0 +1,57 @@ +keyChain = $keyChain; + } + + protected function configure(): void + { + $this->setName('crypto:private-key:info') + ->addArgument('key-name', InputArgument::REQUIRED) + ->setDescription('Get a private or public key information') + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $keyName = $input->getArgument('key-name'); + $key = $this->keyChain->get($keyName); + + $io->table([ + 'name', + 'type', + 'derivation', + 'usage', + 'base64', + ], [[ + $keyName, + $key->isAsymmetricKey() ? 'asymmetric' : 'symmetric', + $key->isPublicKey() ? 'public' : 'private', + $key->isSigningKey() ? 'signing' : 'encryption', + base64_encode($key->getRawKeyMaterial()) + ]]); + return 0; + } +} diff --git a/lib/RoadizCoreBundle/src/Console/PurgeLoginAttemptCommand.php b/lib/RoadizCoreBundle/src/Console/PurgeLoginAttemptCommand.php new file mode 100644 index 00000000..95985673 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Console/PurgeLoginAttemptCommand.php @@ -0,0 +1,54 @@ +managerRegistry = $managerRegistry; + } + + protected function configure(): void + { + $this->setName('login-attempts:purge') + ->setDescription('Purge all login attempts for one IP address') + ->addArgument( + 'ip-address', + InputArgument::REQUIRED + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $this->managerRegistry + ->getRepository(LoginAttempt::class) + ->purgeLoginAttempts($input->getArgument('ip-address')); + + $io->success('All login attempts were deleted for ' . $input->getArgument('ip-address')); + + return 0; + } +} diff --git a/lib/RoadizCoreBundle/src/Console/SolrCommand.php b/lib/RoadizCoreBundle/src/Console/SolrCommand.php new file mode 100644 index 00000000..6e930a20 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Console/SolrCommand.php @@ -0,0 +1,75 @@ +clientRegistry = $clientRegistry; + } + + protected function configure(): void + { + $this->setName('solr:check') + ->setDescription('Check Solr search engine server'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $client = $this->clientRegistry->getClient(); + $this->io = new SymfonyStyle($input, $output); + + if (null !== $client) { + if (true === $this->clientRegistry->isClientReady($client)) { + $this->io->writeln('Solr search engine server is running…'); + } else { + $this->io->error('Solr search engine server does not respond…'); + $this->io->note('See your config.yml file to correct your Solr connexion settings.'); + return 1; + } + } else { + $this->displayBasicConfig(); + } + return 0; + } + + protected function displayBasicConfig(): void + { + if (null !== $this->io) { + $this->io->error('No Solr search engine server has been configured…'); + $this->io->note(<<indexerFactory = $indexerFactory; + } + + protected function configure(): void + { + $this->setName('solr:optimize') + ->setDescription('Optimize Solr search engine index'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $solr = $this->clientRegistry->getClient(); + $this->io = new SymfonyStyle($input, $output); + + if (null !== $solr) { + if (true === $this->clientRegistry->isClientReady($solr)) { + $documentIndexer = $this->indexerFactory->getIndexerFor(Document::class); + if ($documentIndexer instanceof CliAwareIndexer) { + $documentIndexer->setIo($this->io); + } + $documentIndexer->optimizeSolr(); + $this->io->success('Solr core has been optimized.'); + } else { + $this->io->error('Solr search engine server does not respond…'); + $this->io->note('See your config.yml file to correct your Solr connexion settings.'); + return 1; + } + } else { + $this->displayBasicConfig(); + } + return 0; + } +} diff --git a/lib/RoadizCoreBundle/src/Console/SolrReindexCommand.php b/lib/RoadizCoreBundle/src/Console/SolrReindexCommand.php new file mode 100644 index 00000000..eb582902 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Console/SolrReindexCommand.php @@ -0,0 +1,153 @@ +indexerFactory = $indexerFactory; + } + + protected function configure(): void + { + $this->setName('solr:reindex') + ->setDescription('Reindex Solr search engine index') + ->addOption('nodes', null, InputOption::VALUE_NONE, 'Reindex with only nodes.') + ->addOption('documents', null, InputOption::VALUE_NONE, 'Reindex with only documents.') + ->addOption('batch-count', null, InputOption::VALUE_REQUIRED, 'Split reindexing in batch (only for nodes).') + ->addOption('batch-number', null, InputOption::VALUE_REQUIRED, 'Run a selected batch (only for nodes), first batch is 0.'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $solr = $this->clientRegistry->getClient(); + $this->io = new SymfonyStyle($input, $output); + + if (null !== $solr) { + if (true === $this->clientRegistry->isClientReady($solr)) { + if ( + $this->io->confirm( + 'Are you sure to reindex your Node and Document database?', + !$input->isInteractive() + ) + ) { + $stopwatch = new Stopwatch(); + $stopwatch->start('global'); + + if ($input->getOption('documents')) { + $this->executeForDocuments($stopwatch); + } elseif ($input->getOption('nodes')) { + $batchCount = (int) ($input->getOption('batch-count') ?? 1); + $batchNumber = (int) ($input->getOption('batch-number') ?? 0); + $this->executeForNodes($stopwatch, $batchCount, $batchNumber); + } else { + $this->executeForAll($stopwatch); + } + } + } else { + $this->io->error('Solr search engine server does not respond…'); + $this->io->note('See your config.yml file to correct your Solr connexion settings.'); + return 1; + } + } else { + $this->displayBasicConfig(); + } + return 0; + } + + protected function executeForAll(Stopwatch $stopwatch): void + { + // Empty first + $documentIndexer = $this->indexerFactory->getIndexerFor(Document::class); + if ($documentIndexer instanceof CliAwareIndexer) { + $documentIndexer->setIo($this->io); + } + $nodesSourcesIndexer = $this->indexerFactory->getIndexerFor(NodesSources::class); + if ($nodesSourcesIndexer instanceof CliAwareIndexer) { + $nodesSourcesIndexer->setIo($this->io); + } + $nodesSourcesIndexer->emptySolr(); + $documentIndexer->reindexAll(); + $nodesSourcesIndexer->reindexAll(); + + $stopwatch->stop('global'); + $duration = $stopwatch->getEvent('global')->getDuration(); + $this->io->success(sprintf('Node and document database has been re-indexed in %.2d ms.', $duration)); + } + + protected function executeForDocuments(Stopwatch $stopwatch): void + { + $documentIndexer = $this->indexerFactory->getIndexerFor(Document::class); + if ($documentIndexer instanceof CliAwareIndexer) { + $documentIndexer->setIo($this->io); + } + $documentIndexer->emptySolr(SolariumDocumentTranslation::DOCUMENT_TYPE); + $documentIndexer->reindexAll(); + + $stopwatch->stop('global'); + $duration = $stopwatch->getEvent('global')->getDuration(); + $this->io->success(sprintf('Document database has been re-indexed in %.2d ms.', $duration)); + } + + protected function executeForNodes(Stopwatch $stopwatch, int $batchCount, int $batchNumber): void + { + $nodesSourcesIndexer = $this->indexerFactory->getIndexerFor(NodesSources::class); + if ($nodesSourcesIndexer instanceof CliAwareIndexer) { + $nodesSourcesIndexer->setIo($this->io); + } + // Empty first ONLY if one batch or first batch. + if ($batchNumber === 0) { + $nodesSourcesIndexer->emptySolr(SolariumNodeSource::DOCUMENT_TYPE); + } + + if ($nodesSourcesIndexer instanceof BatchIndexer) { + $nodesSourcesIndexer->reindexAll($batchCount, $batchNumber); + } else { + $nodesSourcesIndexer->reindexAll(); + } + + $stopwatch->stop('global'); + $duration = $stopwatch->getEvent('global')->getDuration(); + if ($batchCount > 1) { + $this->io->success(sprintf( + 'Batch %d/%d of node database has been re-indexed in %.2d ms.', + $batchNumber + 1, + $batchCount, + $duration + )); + } else { + $this->io->success(sprintf('Node database has been re-indexed in %.2d ms.', $duration)); + } + } +} diff --git a/lib/RoadizCoreBundle/src/Console/SolrResetCommand.php b/lib/RoadizCoreBundle/src/Console/SolrResetCommand.php new file mode 100644 index 00000000..6cd34a0f --- /dev/null +++ b/lib/RoadizCoreBundle/src/Console/SolrResetCommand.php @@ -0,0 +1,68 @@ +indexerFactory = $indexerFactory; + } + + protected function configure(): void + { + $this->setName('solr:reset') + ->setDescription('Reset Solr search engine index'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $solr = $this->clientRegistry->getClient(); + $this->io = new SymfonyStyle($input, $output); + + if (null !== $solr) { + if (true === $this->clientRegistry->isClientReady($solr)) { + $confirmation = new ConfirmationQuestion( + 'Are you sure to reset Solr index?', + false + ); + if ($this->io->askQuestion($confirmation)) { + $indexer = $this->indexerFactory->getIndexerFor(NodesSources::class); + if ($indexer instanceof CliAwareIndexer) { + $indexer->setIo($this->io); + } + $indexer->emptySolr(); + $this->io->success('Solr index resetted.'); + } + } else { + $this->io->error('Solr search engine server does not respond…'); + $this->io->note('See your config.yml file to correct your Solr connexion settings.'); + return 1; + } + } else { + $this->displayBasicConfig(); + } + return 0; + } +} diff --git a/lib/RoadizCoreBundle/src/Console/ThemeAwareCommandInterface.php b/lib/RoadizCoreBundle/src/Console/ThemeAwareCommandInterface.php new file mode 100644 index 00000000..03ea9ccc --- /dev/null +++ b/lib/RoadizCoreBundle/src/Console/ThemeAwareCommandInterface.php @@ -0,0 +1,9 @@ +managerRegistry = $managerRegistry; + } + + protected function configure(): void + { + $this->setName('translations:list') + ->setDescription('List translations'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $translations = $this->managerRegistry + ->getRepository(Translation::class) + ->findAll(); + + if (count($translations) > 0) { + $tableContent = []; + /** @var Translation $trans */ + foreach ($translations as $trans) { + $tableContent[] = [ + $trans->getId(), + $trans->getName(), + $trans->getLocale(), + (!$trans->isAvailable() ? 'X' : ''), + ($trans->isDefaultTranslation() ? 'X' : ''), + ]; + } + $io->table(['Id', 'Name', 'Locale', 'Disabled', 'Default'], $tableContent); + } else { + $io->error('No available translations.'); + } + return 0; + } +} diff --git a/lib/RoadizCoreBundle/src/Console/TranslationsCreationCommand.php b/lib/RoadizCoreBundle/src/Console/TranslationsCreationCommand.php new file mode 100644 index 00000000..a4ac9e5d --- /dev/null +++ b/lib/RoadizCoreBundle/src/Console/TranslationsCreationCommand.php @@ -0,0 +1,92 @@ +managerRegistry = $managerRegistry; + } + + protected function configure(): void + { + $this->setName('translations:create') + ->setDescription('Create a translation') + ->addArgument( + 'name', + InputArgument::REQUIRED, + 'Translation name' + ) + ->addArgument( + 'locale', + InputArgument::REQUIRED, + 'Translation locale' + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $name = $input->getArgument('name'); + $locale = $input->getArgument('locale'); + + if ($name) { + $translationByName = $this->managerRegistry + ->getRepository(Translation::class) + ->findOneByName($name); + $translationByLocale = $this->managerRegistry + ->getRepository(Translation::class) + ->findOneByLocale($locale); + + $confirmation = new ConfirmationQuestion( + 'Are you sure to create ' . $name . ' (' . $locale . ') translation?', + false + ); + + if (null !== $translationByName) { + $io->error('Translation ' . $name . ' already exists.'); + return 1; + } elseif (null !== $translationByLocale) { + $io->error('Translation locale ' . $locale . ' is already used.'); + return 1; + } else { + if ( + $io->askQuestion( + $confirmation + ) + ) { + $newTrans = new Translation(); + $newTrans->setName($name) + ->setLocale($locale); + + $this->managerRegistry->getManagerForClass(Translation::class)->persist($newTrans); + $this->managerRegistry->getManagerForClass(Translation::class)->flush(); + + $io->success('New ' . $newTrans->getName() . ' translation for ' . $newTrans->getLocale() . ' locale.'); + } + } + } + return 0; + } +} diff --git a/lib/RoadizCoreBundle/src/Console/TranslationsDeleteCommand.php b/lib/RoadizCoreBundle/src/Console/TranslationsDeleteCommand.php new file mode 100644 index 00000000..eb3e5a9d --- /dev/null +++ b/lib/RoadizCoreBundle/src/Console/TranslationsDeleteCommand.php @@ -0,0 +1,83 @@ +managerRegistry = $managerRegistry; + } + + protected function configure(): void + { + $this->setName('translations:delete') + ->setDescription('Delete a translation') + ->addArgument( + 'locale', + InputArgument::REQUIRED, + 'Translation locale' + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $locale = $input->getArgument('locale'); + + $translation = $this->managerRegistry + ->getRepository(Translation::class) + ->findOneByLocale($locale); + $translationCount = $this->managerRegistry + ->getRepository(Translation::class) + ->countBy([]); + + if ($translationCount < 2) { + $io->error('You cannot delete the only one available translation!'); + return 1; + } elseif ($translation !== null) { + $io->note('///////////////////////////////' . PHP_EOL . + '/////////// WARNING ///////////' . PHP_EOL . + '///////////////////////////////' . PHP_EOL . + 'This operation cannot be undone.' . PHP_EOL . + 'Deleting a translation, you will automatically delete every translated tags, node-sources, url-aliases and documents.'); + $confirmation = new ConfirmationQuestion( + 'Are you sure to delete ' . $translation->getName() . ' (' . $translation->getLocale() . ') translation?', + false + ); + if ( + $io->askQuestion( + $confirmation + ) + ) { + $this->managerRegistry->getManagerForClass(Translation::class)->remove($translation); + $this->managerRegistry->getManagerForClass(Translation::class)->flush(); + $io->success('Translation deleted.'); + } + } else { + $io->error('Translation for locale ' . $locale . ' does not exist.'); + return 1; + } + return 0; + } +} diff --git a/lib/RoadizCoreBundle/src/Console/TranslationsDisableCommand.php b/lib/RoadizCoreBundle/src/Console/TranslationsDisableCommand.php new file mode 100644 index 00000000..abf6317b --- /dev/null +++ b/lib/RoadizCoreBundle/src/Console/TranslationsDisableCommand.php @@ -0,0 +1,72 @@ +managerRegistry = $managerRegistry; + } + + protected function configure(): void + { + $this->setName('translations:disable') + ->setDescription('Disables a translation') + ->addArgument( + 'locale', + InputArgument::REQUIRED, + 'Translation locale' + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $locale = $input->getArgument('locale'); + + $translation = $this->managerRegistry + ->getRepository(Translation::class) + ->findOneByLocale($locale); + + if ($translation !== null) { + $confirmation = new ConfirmationQuestion( + 'Are you sure to disable ' . $translation->getName() . ' (' . $translation->getLocale() . ') translation?', + false + ); + if ( + $io->askQuestion( + $confirmation + ) + ) { + $translation->setAvailable(false); + $this->managerRegistry->getManagerForClass(Translation::class)->flush(); + $io->success('Translation disabled.'); + } + } else { + $io->error('Translation for locale ' . $locale . ' does not exist.'); + return 1; + } + return 0; + } +} diff --git a/lib/RoadizCoreBundle/src/Console/TranslationsEnableCommand.php b/lib/RoadizCoreBundle/src/Console/TranslationsEnableCommand.php new file mode 100644 index 00000000..f7fe0530 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Console/TranslationsEnableCommand.php @@ -0,0 +1,72 @@ +managerRegistry = $managerRegistry; + } + + protected function configure(): void + { + $this->setName('translations:enable') + ->setDescription('Enables a translation') + ->addArgument( + 'locale', + InputArgument::REQUIRED, + 'Translation locale' + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $locale = $input->getArgument('locale'); + + $translation = $this->managerRegistry + ->getRepository(Translation::class) + ->findOneByLocale($locale); + + if ($translation !== null) { + $confirmation = new ConfirmationQuestion( + 'Are you sure to enable ' . $translation->getName() . ' (' . $translation->getLocale() . ') translation?', + false + ); + if ( + $io->askQuestion( + $confirmation + ) + ) { + $translation->setAvailable(true); + $this->managerRegistry->getManagerForClass(Translation::class)->flush(); + $io->success('Translation enabled.'); + } + } else { + $io->error('Translation for locale ' . $locale . ' does not exist.'); + return 1; + } + return 0; + } +} diff --git a/lib/RoadizCoreBundle/src/Console/UsersCommand.php b/lib/RoadizCoreBundle/src/Console/UsersCommand.php new file mode 100644 index 00000000..56ffa617 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Console/UsersCommand.php @@ -0,0 +1,124 @@ +managerRegistry = $managerRegistry; + } + + protected function configure(): void + { + $this->setName('users:list') + ->setDescription('List all users or just one') + ->addArgument( + 'username', + InputArgument::OPTIONAL, + 'User name' + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $name = $input->getArgument('username'); + + if ($name) { + /** @var User|null $user */ + $user = $this->managerRegistry + ->getRepository(User::class) + ->findOneBy(['username' => $name]); + + if ($user === null) { + $io->error('User “' . $name . '” does not exist… use users:create to add a new user.'); + } else { + $tableContent = [[ + $user->getId(), + $user->getUsername(), + $user->getEmail(), + (!$user->isEnabled() ? 'X' : ''), + ($user->getExpired() ? 'X' : ''), + (!$user->isAccountNonLocked() ? 'X' : ''), + implode(' ', $user->getGroupNames()), + implode(' ', $user->getRoles()), + ]]; + $io->table( + ['Id', 'Username', 'Email', 'Disabled', 'Expired', 'Locked', 'Groups', 'Roles'], + $tableContent + ); + } + } else { + $users = $this->managerRegistry + ->getRepository(User::class) + ->findAll(); + + if (count($users) > 0) { + $tableContent = []; + foreach ($users as $user) { + $tableContent[] = [ + $user->getId(), + $user->getUsername(), + $user->getEmail(), + (!$user->isEnabled() ? 'X' : ''), + ($user->getExpired() ? 'X' : ''), + (!$user->isAccountNonLocked() ? 'X' : ''), + implode(' ', $user->getGroupNames()), + implode(' ', $user->getRoles()), + ]; + } + + $io->table( + ['Id', 'Username', 'Email', 'Disabled', 'Expired', 'Locked', 'Groups', 'Roles'], + $tableContent + ); + } else { + $io->warning('No available users.'); + } + } + return 0; + } + + /** + * Get role by name, and create it if it does not exist. + * + * @param string $roleName + * + * @return Role + */ + public function getRole(string $roleName = Role::ROLE_SUPERADMIN) + { + $role = $this->managerRegistry + ->getRepository(Role::class) + ->findOneBy(['name' => $roleName]); + + if ($role === null) { + $role = new Role($roleName); + $this->managerRegistry->getManagerForClass(Role::class)->persist($role); + $this->managerRegistry->getManagerForClass(Role::class)->flush(); + } + + return $role; + } +} diff --git a/lib/RoadizCoreBundle/src/Console/UsersCreationCommand.php b/lib/RoadizCoreBundle/src/Console/UsersCreationCommand.php new file mode 100644 index 00000000..7351fe0a --- /dev/null +++ b/lib/RoadizCoreBundle/src/Console/UsersCreationCommand.php @@ -0,0 +1,161 @@ +setName('users:create') + ->setDescription('Create a user. Without --password a random password will be generated and sent by email. Check if "email_sender" setting is valid.') + ->addOption('email', 'm', InputOption::VALUE_REQUIRED, 'Set user email.') + ->addOption('password', 'p', InputOption::VALUE_REQUIRED, 'Set user password (typing plain password in command-line is insecure).') + ->addOption('back-end', 'b', InputOption::VALUE_NONE, 'Add ROLE_BACKEND_USER to user.') + ->addOption('super-admin', 's', InputOption::VALUE_NONE, 'Add ROLE_SUPERADMIN to user.') + ->addUsage('--email=test@test.com --password=secret --back-end --super-admin test') + ->addArgument( + 'username', + InputArgument::REQUIRED, + 'Username' + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $name = $input->getArgument('username'); + + if ($name) { + /** @var User|null $user */ + $user = $this->managerRegistry + ->getRepository(User::class) + ->findOneBy(['username' => $name]); + + if (null === $user) { + $user = $this->executeUserCreation($name, $input, $output); + + // Change password right away + $command = $this->getApplication()->find('users:password'); + $arguments = [ + 'username' => $user->getUsername(), + ]; + $passwordInput = new ArrayInput($arguments); + return $command->run($passwordInput, $output); + } else { + throw new \InvalidArgumentException('User “' . $name . '” already exists.'); + } + } + return 0; + } + + /** + * @param string $username + * @param InputInterface $input + * @param OutputInterface $output + * + * @return User + */ + private function executeUserCreation( + string $username, + InputInterface $input, + OutputInterface $output + ): User { + $user = new User(); + $io = new SymfonyStyle($input, $output); + if (!$input->hasOption('password')) { + $user->sendCreationConfirmationEmail(true); + } + $user->setUsername($username); + + if ($input->isInteractive() && !$input->getOption('email')) { + /* + * Interactive + */ + do { + $questionEmail = new Question( + 'Email' + ); + $email = $io->askQuestion( + $questionEmail + ); + } while ( + !filter_var($email, FILTER_VALIDATE_EMAIL) || + $this->managerRegistry->getRepository(User::class)->emailExists($email) + ); + } else { + /* + * From CLI + */ + $email = $input->getOption('email'); + if ($this->managerRegistry->getRepository(User::class)->emailExists($email)) { + throw new \InvalidArgumentException('Email already exists.'); + } + if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { + throw new \InvalidArgumentException('Email is not valid.'); + } + } + + $user->setEmail($email); + + if ($input->isInteractive() && !$input->getOption('back-end')) { + $questionBack = new ConfirmationQuestion( + 'Is user a backend user?', + false + ); + if ( + $io->askQuestion( + $questionBack + ) + ) { + $user->addRoleEntity($this->getRole(Role::ROLE_BACKEND_USER)); + } + } elseif ($input->getOption('back-end') === true) { + $user->addRoleEntity($this->getRole(Role::ROLE_BACKEND_USER)); + } + + if ($input->isInteractive() && !$input->getOption('super-admin')) { + $questionAdmin = new ConfirmationQuestion( + 'Is user a super-admin user?', + false + ); + if ( + $io->askQuestion( + $questionAdmin + ) + ) { + $user->addRoleEntity($this->getRole(Role::ROLE_SUPERADMIN)); + } + } elseif ($input->getOption('super-admin') === true) { + $user->addRoleEntity($this->getRole(Role::ROLE_SUPERADMIN)); + } + + if ($input->getOption('password')) { + if (strlen($input->getOption('password')) < 5) { + throw new \InvalidArgumentException('Password is too short.'); + } + + $user->setPlainPassword($input->getOption('password')); + } + + $this->managerRegistry->getManagerForClass(User::class)->persist($user); + $this->managerRegistry->getManagerForClass(User::class)->flush(); + + $io->success('User “' . $username . '”<' . $email . '> created no password.'); + return $user; + } +} diff --git a/lib/RoadizCoreBundle/src/Console/UsersDeleteCommand.php b/lib/RoadizCoreBundle/src/Console/UsersDeleteCommand.php new file mode 100644 index 00000000..6fb754da --- /dev/null +++ b/lib/RoadizCoreBundle/src/Console/UsersDeleteCommand.php @@ -0,0 +1,63 @@ +setName('users:delete') + ->setDescription('Delete a user permanently') + ->addArgument( + 'username', + InputArgument::REQUIRED, + 'Username' + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $name = $input->getArgument('username'); + + if ($name) { + /** @var User|null $user */ + $user = $this->managerRegistry + ->getRepository(User::class) + ->findOneBy(['username' => $name]); + + if (null !== $user) { + $confirmation = new ConfirmationQuestion( + 'Do you really want to delete user “' . $user->getUsername() . '”?', + false + ); + if ( + !$input->isInteractive() || $io->askQuestion( + $confirmation + ) + ) { + $this->managerRegistry->getManagerForClass(User::class)->remove($user); + $this->managerRegistry->getManagerForClass(User::class)->flush(); + $io->success('User “' . $name . '” deleted.'); + } else { + $io->warning('User “' . $name . '” was not deleted.'); + } + } else { + throw new \InvalidArgumentException('User “' . $name . '” does not exist.'); + } + } + return 0; + } +} diff --git a/lib/RoadizCoreBundle/src/Console/UsersDisableCommand.php b/lib/RoadizCoreBundle/src/Console/UsersDisableCommand.php new file mode 100644 index 00000000..a5d3c5f7 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Console/UsersDisableCommand.php @@ -0,0 +1,63 @@ +setName('users:disable') + ->setDescription('Disable a user') + ->addArgument( + 'username', + InputArgument::REQUIRED, + 'Username' + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $name = $input->getArgument('username'); + + if ($name) { + /** @var User|null $user */ + $user = $this->managerRegistry + ->getRepository(User::class) + ->findOneBy(['username' => $name]); + + if (null !== $user) { + $confirmation = new ConfirmationQuestion( + 'Do you really want to disable user “' . $user->getUsername() . '”?', + false + ); + if ( + !$input->isInteractive() || $io->askQuestion( + $confirmation + ) + ) { + $user->setEnabled(false); + $this->managerRegistry->getManagerForClass(User::class)->flush(); + $io->success('User “' . $name . '” disabled.'); + } else { + $io->warning('User “' . $name . '” was not disabled.'); + } + } else { + throw new \InvalidArgumentException('User “' . $name . '” does not exist.'); + } + } + return 0; + } +} diff --git a/lib/RoadizCoreBundle/src/Console/UsersEnableCommand.php b/lib/RoadizCoreBundle/src/Console/UsersEnableCommand.php new file mode 100644 index 00000000..f166e74b --- /dev/null +++ b/lib/RoadizCoreBundle/src/Console/UsersEnableCommand.php @@ -0,0 +1,62 @@ +setName('users:enable') + ->setDescription('Enable a user') + ->addArgument( + 'username', + InputArgument::REQUIRED, + 'Username' + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $name = $input->getArgument('username'); + + if ($name) { + $user = $this->managerRegistry + ->getRepository(User::class) + ->findOneBy(['username' => $name]); + + if (null !== $user) { + $confirmation = new ConfirmationQuestion( + 'Do you really want to enable user “' . $user->getUsername() . '”?', + false + ); + if ( + !$input->isInteractive() || $io->askQuestion( + $confirmation + ) + ) { + $user->setEnabled(true); + $this->managerRegistry->getManagerForClass(User::class)->flush(); + $io->success('User “' . $name . '” was enabled.'); + } else { + $io->warning('User “' . $name . '” was not enabled'); + } + } else { + throw new \InvalidArgumentException('User “' . $name . '” does not exist.'); + } + } + return 0; + } +} diff --git a/lib/RoadizCoreBundle/src/Console/UsersPasswordCommand.php b/lib/RoadizCoreBundle/src/Console/UsersPasswordCommand.php new file mode 100644 index 00000000..6472dfd7 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Console/UsersPasswordCommand.php @@ -0,0 +1,77 @@ +passwordGenerator = $passwordGenerator; + } + + protected function configure(): void + { + $this->setName('users:password') + ->setDescription('Regenerate a new password for user') + ->addArgument( + 'username', + InputArgument::REQUIRED, + 'Username' + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $name = $input->getArgument('username'); + + if ($name) { + /** @var User|null $user */ + $user = $this->managerRegistry + ->getRepository(User::class) + ->findOneBy(['username' => $name]); + + if (null !== $user) { + $confirmation = new ConfirmationQuestion( + 'Do you really want to regenerate user “' . $user->getUsername() . '” password?', + false + ); + if ( + !$input->isInteractive() || $io->askQuestion( + $confirmation + ) + ) { + $user->setPlainPassword($this->passwordGenerator->generatePassword(12)); + $this->managerRegistry->getManagerForClass(User::class)->flush(); + $io->success('A new password was regenerated for ' . $name . ': ' . $user->getPlainPassword()); + } else { + $io->warning('User password was not changed.'); + } + } else { + throw new \InvalidArgumentException('User “' . $name . '” does not exist.'); + } + } + return 0; + } +} diff --git a/lib/RoadizCoreBundle/src/Console/UsersRolesCommand.php b/lib/RoadizCoreBundle/src/Console/UsersRolesCommand.php new file mode 100644 index 00000000..40b725b9 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Console/UsersRolesCommand.php @@ -0,0 +1,110 @@ +rolesBag = $rolesBag; + } + + protected function configure(): void + { + $this->setName('users:roles') + ->setDescription('Manage user roles') + ->addArgument( + 'username', + InputArgument::REQUIRED, + 'Username' + ) + ->addOption( + 'add', + 'a', + InputOption::VALUE_NONE, + 'Add roles to a user' + ) + ->addOption( + 'remove', + 'r', + InputOption::VALUE_NONE, + 'Remove roles from a user' + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $name = $input->getArgument('username'); + + if ($name) { + /** @var User|null $user */ + $user = $this->managerRegistry + ->getRepository(User::class) + ->findOneBy(['username' => $name]); + + if (null !== $user) { + if ($input->getOption('add')) { + $roles = $this->managerRegistry + ->getRepository(Role::class) + ->getAllRoleName(); + + $question = new Question( + 'Enter the role name to add' + ); + $question->setAutocompleterValues($roles); + + do { + $role = $io->askQuestion($question); + if ($role != "") { + $user->addRoleEntity($this->rolesBag->get($role)); + $this->managerRegistry->getManagerForClass(User::class)->flush(); + $io->success('Role: ' . $role . ' added.'); + } + } while ($role != ""); + } elseif ($input->getOption('remove')) { + do { + $roles = $user->getRoles(); + $question = new Question( + 'Enter the role name to remove' + ); + $question->setAutocompleterValues($roles); + + $role = $io->askQuestion($question); + if (in_array($role, $roles)) { + $user->removeRoleEntity($this->rolesBag->get($role)); + $this->managerRegistry->getManagerForClass(User::class)->flush(); + $io->success('Role: ' . $role . ' removed.'); + } + } while ($role != ""); + } + } else { + throw new \InvalidArgumentException('User “' . $name . '” does not exist.'); + } + } + return 0; + } +} diff --git a/lib/RoadizCoreBundle/src/Console/VersionsPurgeCommand.php b/lib/RoadizCoreBundle/src/Console/VersionsPurgeCommand.php new file mode 100644 index 00000000..8369732c --- /dev/null +++ b/lib/RoadizCoreBundle/src/Console/VersionsPurgeCommand.php @@ -0,0 +1,151 @@ +managerRegistry = $managerRegistry; + } + + protected function configure(): void + { + $this->setName('versions:purge') + ->setDescription('Purge entities versions') + ->setHelp(<<before a given date-time +OR by keeping at least count versions. + +This command does not alter active node-sources, document translations +or tag translations, it only deletes versioned log entries. +EOT + ) + ->addOption( + 'before', + 'b', + InputOption::VALUE_REQUIRED, + 'Purge versions older than before date (any format accepted by \DateTime).' + ) + ->addOption( + 'count', + 'c', + InputOption::VALUE_REQUIRED, + 'Keeps only count versions for each entities (count must be greater than 1).' + ) + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + if ($input->hasOption('before') && $input->getOption('before') != '') { + $this->purgeByDate($input, $output); + } elseif ($input->hasOption('count')) { + if ((int) $input->getOption('count') < 2) { + throw new \InvalidArgumentException('Count option must be greater than 1.'); + } + $this->purgeByCount($input, $output); + } else { + throw new \InvalidArgumentException('Choose an option between --before or --count'); + } + return 0; + } + + private function purgeByDate(InputInterface $input, OutputInterface $output): void + { + $io = new SymfonyStyle($input, $output); + $em = $this->managerRegistry->getManagerForClass(UserLogEntry::class); + $dateTime = new \DateTime($input->getOption('before')); + + if ($dateTime >= new \DateTime()) { + throw new \InvalidArgumentException('Before date must be in the past.'); + } + $qb = $em->getRepository(UserLogEntry::class)->createQueryBuilder('l'); + $count = $qb->select($qb->expr()->countDistinct('l')) + ->where($qb->expr()->lt('l.loggedAt', ':loggedAt')) + ->setParameter('loggedAt', $dateTime) + ->getQuery() + ->getSingleScalarResult() + ; + $question = new ConfirmationQuestion(sprintf( + 'Do you want to purge %s version(s) before %s?', + $count, + $dateTime->format('c') + ), false); + if ( + !$input->isInteractive() || $io->askQuestion( + $question + ) + ) { + $qb = $em->getRepository(UserLogEntry::class)->createQueryBuilder('l'); + $result = $qb->delete(UserLogEntry::class, 'l') + ->where($qb->expr()->lt('l.loggedAt', ':loggedAt')) + ->setParameter('loggedAt', $dateTime) + ->getQuery() + ->execute() + ; + $io->success(sprintf('%s version(s) were deleted.', $result)); + } + } + + private function purgeByCount(InputInterface $input, OutputInterface $output): void + { + $deleteCount = 0; + $io = new SymfonyStyle($input, $output); + $count = (int) $input->getOption('count'); + $em = $this->managerRegistry->getManagerForClass(UserLogEntry::class); + + $question = new ConfirmationQuestion(sprintf( + 'Do you want to purge all entities versions and to keep only the latest %s?', + $count + ), false); + if ( + !$input->isInteractive() || $io->askQuestion( + $question + ) + ) { + $qb = $em->getRepository(UserLogEntry::class)->createQueryBuilder('l'); + $objects = $qb->select('MAX(l.version) as maxVersion', 'l.objectId', 'l.objectClass') + ->groupBy('l.objectId', 'l.objectClass') + ->getQuery() + ->getArrayResult() + ; + $deleteQuery = $qb->delete(UserLogEntry::class, 'l') + ->andWhere($qb->expr()->eq('l.objectId', ':objectId')) + ->andWhere($qb->expr()->eq('l.objectClass', ':objectClass')) + ->andWhere($qb->expr()->lt('l.version', ':lowestVersion')) + ->getQuery() + ; + + foreach ($objects as $object) { + $lowestVersion = (int) $object['maxVersion'] - $count; + if ($lowestVersion > 1) { + $deleteCount += $deleteQuery->execute([ + 'objectId' => $object['objectId'], + 'objectClass' => $object['objectClass'], + 'lowestVersion' => $lowestVersion + ]); + } + } + + $io->success(sprintf('%s version(s) were deleted.', $deleteCount)); + } + } +} diff --git a/lib/RoadizCoreBundle/src/Controller/CustomFormController.php b/lib/RoadizCoreBundle/src/Controller/CustomFormController.php new file mode 100644 index 00000000..1bc9fc56 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Controller/CustomFormController.php @@ -0,0 +1,445 @@ +emailManager = $emailManager; + $this->settingsBag = $settingsBag; + $this->logger = $logger; + $this->translator = $translator; + $this->customFormHelperFactory = $customFormHelperFactory; + $this->liform = $liform; + $this->serializer = $serializer; + $this->formErrorSerializer = $formErrorSerializer; + $this->registry = $registry; + $this->customFormLimiter = $customFormLimiter; + $this->documentsStorage = $documentsStorage; + $this->previewResolver = $previewResolver; + } + + protected function validateCustomForm(?CustomForm $customForm): void + { + if (null === $customForm) { + throw new NotFoundHttpException('Custom form not found'); + } + if (!$customForm->isFormStillOpen()) { + throw new NotFoundHttpException('Custom form is closed'); + } + } + + protected function getTranslationFromRequest(?Request $request): TranslationInterface + { + $locale = null; + + if (null !== $request) { + $locale = $request->query->get('_locale'); + + /* + * If no _locale query param is defined check Accept-Language header + */ + if (null === $locale) { + $locale = $request->getPreferredLanguage($this->getTranslationRepository()->getAllLocales()); + } + } + /* + * Then fallback to default CMS locale + */ + if (null === $locale) { + $translation = $this->getTranslationRepository()->findDefault(); + } elseif ($this->previewResolver->isPreview()) { + $translation = $this->getTranslationRepository() + ->findOneByLocaleOrOverrideLocale((string) $locale); + } else { + $translation = $this->getTranslationRepository() + ->findOneAvailableByLocaleOrOverrideLocale((string) $locale); + } + if (null === $translation) { + throw new NotFoundHttpException('No translation for locale ' . $locale); + } + return $translation; + } + + /** + * @param Request $request + * @param int $id + * @return JsonResponse + */ + public function definitionAction(Request $request, int $id): JsonResponse + { + /** @var CustomForm|null $customForm */ + $customForm = $this->registry->getRepository(CustomForm::class)->find($id); + $this->validateCustomForm($customForm); + + $helper = $this->customFormHelperFactory->createHelper($customForm); + $translation = $this->getTranslationFromRequest($request); + $request->setLocale($translation->getPreferredLocale()); + if ($this->translator instanceof LocaleAwareInterface) { + $this->translator->setLocale($translation->getPreferredLocale()); + } + $schema = json_encode($this->liform->transform($helper->getForm($request, false, false))); + + return new JsonResponse( + $schema, + Response::HTTP_OK, + [], + true + ); + } + + /** + * @param Request $request + * @param int $id + * @return Response + * @throws Exception + */ + public function postAction(Request $request, int $id): Response + { + // create a limiter based on a unique identifier of the client + $limiter = $this->customFormLimiter->create($request->getClientIp()); + // only claims 1 token if it's free at this moment + $limit = $limiter->consume(); + $headers = [ + 'X-RateLimit-Remaining' => $limit->getRemainingTokens(), + 'X-RateLimit-Retry-After' => $limit->getRetryAfter()->getTimestamp(), + 'X-RateLimit-Limit' => $limit->getLimit(), + ]; + if (false === $limit->isAccepted()) { + throw new TooManyRequestsHttpException($limit->getRetryAfter()->getTimestamp()); + } + + /** @var CustomForm|null $customForm */ + $customForm = $this->registry->getRepository(CustomForm::class)->find($id); + $this->validateCustomForm($customForm); + + $translation = $this->getTranslationFromRequest($request); + $request->setLocale($translation->getPreferredLocale()); + if ($this->translator instanceof LocaleAwareInterface) { + $this->translator->setLocale($translation->getPreferredLocale()); + } + + $mixed = $this->prepareAndHandleCustomFormAssignation( + $request, + $customForm, + new JsonResponse(null, Response::HTTP_ACCEPTED, $headers), + false, + null, + false + ); + + if ($mixed instanceof Response) { + $mixed->prepare($request); + return $mixed; + } + + if (is_array($mixed) && $mixed['formObject'] instanceof FormInterface) { + if ($mixed['formObject']->isSubmitted()) { + $errorPayload = [ + 'status' => Response::HTTP_BAD_REQUEST, + 'errorsPerForm' => $this->formErrorSerializer->getErrorsAsArray($mixed['formObject']) + ]; + return new JsonResponse( + $this->serializer->serialize($errorPayload, 'json'), + Response::HTTP_BAD_REQUEST, + $headers, + true + ); + } + } + + throw new BadRequestHttpException('Form has not been submitted'); + } + + /** + * @param Request $request + * @param int $customFormId + * @return Response + * @throws Exception + */ + public function addAction(Request $request, int $customFormId): Response + { + /** @var CustomForm $customForm */ + $customForm = $this->registry->getRepository(CustomForm::class)->find($customFormId); + $this->validateCustomForm($customForm); + + $mixed = $this->prepareAndHandleCustomFormAssignation( + $request, + $customForm, + new RedirectResponse( + $this->generateUrl( + 'customFormSentAction', + ["customFormId" => $customFormId] + ) + ) + ); + + if ($mixed instanceof RedirectResponse) { + $mixed->prepare($request); + return $mixed->send(); + } else { + return $this->render('@RoadizCore/customForm/customForm.html.twig', $mixed); + } + } + + /** + * @param Request $request + * @param int $customFormId + * @return Response + */ + public function sentAction(Request $request, int $customFormId): Response + { + $assignation = []; + /** @var CustomForm|null $customForm */ + $customForm = $this->registry->getRepository(CustomForm::class)->find($customFormId); + $this->validateCustomForm($customForm); + + $assignation['customForm'] = $customForm; + return $this->render('@RoadizCore/customForm/customFormSent.html.twig', $assignation); + } + + /** + * Send an answer form by Email. + * + * @param CustomFormAnswer $answer + * @param array $assignation + * @param string|array|null $receiver + * @return bool + * @throws TransportExceptionInterface + * @throws LoaderError + * @throws RuntimeError + * @throws SyntaxError + */ + public function sendAnswer( + CustomFormAnswer $answer, + array $assignation, + $receiver + ): bool { + $defaultSender = $this->settingsBag->get('email_sender'); + $defaultSender = !empty($defaultSender) ? $defaultSender : 'sender@roadiz.io'; + $this->emailManager->setAssignation($assignation); + $this->emailManager->setEmailTemplate('@RoadizCore/email/forms/answerForm.html.twig'); + $this->emailManager->setEmailPlainTextTemplate('@RoadizCore/email/forms/answerForm.txt.twig'); + $this->emailManager->setSubject($assignation['title']); + $this->emailManager->setEmailTitle($assignation['title']); + $this->emailManager->setSender($defaultSender); + + try { + foreach ($answer->getAnswers() as $customFormAnswerAttr) { + /** @var DocumentInterface $document */ + foreach ($customFormAnswerAttr->getDocuments() as $document) { + $this->emailManager->addResource( + $this->documentsStorage->readStream($document->getMountPath()), + $document->getFilename(), + $this->documentsStorage->mimeType($document->getMountPath()) + ); + } + } + } catch (FilesystemException $exception) { + $this->logger->error($exception->getMessage()); + } + + if (empty($receiver)) { + $this->emailManager->setReceiver($defaultSender); + } else { + $this->emailManager->setReceiver($receiver); + } + + // Send the message + $this->emailManager->send(); + return true; + } + + /** + * Prepare and handle a CustomForm Form then send a confirm email. + * + * * This method will return an assignation **array** if form is not validated. + * * customForm + * * fields + * * form + * * If form is validated, **RedirectResponse** will be returned. + * + * @param Request $request + * @param CustomForm $customFormsEntity + * @param Response $response + * @param boolean $forceExpanded + * @param string|null $emailSender + * @param bool $prefix + * @return array|Response + * @throws LoaderError + * @throws RuntimeError + * @throws SyntaxError + * @throws TransportExceptionInterface + */ + public function prepareAndHandleCustomFormAssignation( + Request $request, + CustomForm $customFormsEntity, + Response $response, + bool $forceExpanded = false, + ?string $emailSender = null, + bool $prefix = true + ) { + $assignation = []; + $assignation['customForm'] = $customFormsEntity; + $assignation['fields'] = $customFormsEntity->getFields(); + $helper = $this->customFormHelperFactory->createHelper($customFormsEntity); + $form = $helper->getForm( + $request, + $forceExpanded, + $prefix + ); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + try { + /* + * Parse form data and create answer. + */ + $answer = $helper->parseAnswerFormData($form, null, $request->getClientIp()); + + /* + * Prepare field assignation for email content. + */ + $assignation["emailFields"] = [ + ["name" => "ip.address", "value" => $answer->getIp()], + ["name" => "submittedAt", "value" => $answer->getSubmittedAt()->format('Y-m-d H:i:s')], + ]; + $assignation["emailFields"] = array_merge( + $assignation["emailFields"], + $answer->toArray(false) + ); + + $msg = $this->translator->trans( + 'customForm.%name%.send', + ['%name%' => $customFormsEntity->getDisplayName()] + ); + + $session = $request->getSession(); + if ($session instanceof Session) { + $session->getFlashBag()->add('confirm', $msg); + } + $this->logger->info($msg); + + $assignation['title'] = $this->translator->trans( + 'new.answer.form.%site%', + ['%site%' => $customFormsEntity->getDisplayName()] + ); + + if (null !== $emailSender && false !== filter_var($emailSender, FILTER_VALIDATE_EMAIL)) { + $assignation['mailContact'] = $emailSender; + } else { + $assignation['mailContact'] = $this->settingsBag->get('email_sender'); + } + + /* + * Send answer notification + */ + $receiver = array_filter( + array_map('trim', explode(',', $customFormsEntity->getEmail() ?? '')) + ); + $receiver = array_map(function (string $email) { + return new Address($email); + }, $receiver); + $this->sendAnswer( + $answer, + [ + 'mailContact' => $assignation['mailContact'], + 'fields' => $assignation["emailFields"], + 'customForm' => $customFormsEntity, + 'title' => $this->translator->trans( + 'new.answer.form.%site%', + ['%site%' => $customFormsEntity->getDisplayName()] + ), + ], + $receiver + ); + + return $response; + } catch (EntityAlreadyExistsException $e) { + $form->addError(new FormError($e->getMessage())); + } + } + + $assignation['form'] = $form->createView(); + $assignation['formObject'] = $form; + return $assignation; + } + + protected function getTranslationRepository(): TranslationRepository + { + $repository = $this->registry->getRepository(TranslationInterface::class); + if (!$repository instanceof TranslationRepository) { + throw new \RuntimeException( + 'Translation repository must be instance of ' . + TranslationRepository::class + ); + } + return $repository; + } +} diff --git a/lib/RoadizCoreBundle/src/Controller/DefaultNodeSourceController.php b/lib/RoadizCoreBundle/src/Controller/DefaultNodeSourceController.php new file mode 100644 index 00000000..541668b2 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Controller/DefaultNodeSourceController.php @@ -0,0 +1,19 @@ +render('@RoadizCore/nodeSource/default.html.twig', [ + 'nodeSource' => $nodeSource + ]); + } +} diff --git a/lib/RoadizCoreBundle/src/Controller/HealthCheckController.php b/lib/RoadizCoreBundle/src/Controller/HealthCheckController.php new file mode 100644 index 00000000..4bff540b --- /dev/null +++ b/lib/RoadizCoreBundle/src/Controller/HealthCheckController.php @@ -0,0 +1,52 @@ +healthCheckToken = $healthCheckToken; + $this->appVersion = $appVersion; + $this->cmsVersion = $cmsVersion; + $this->cmsVersionPrefix = $cmsVersionPrefix; + } + + public function __invoke(Request $request): JsonResponse + { + if ( + !empty($this->healthCheckToken) && + $request->headers->get('x-health-check') !== $this->healthCheckToken + ) { + throw new NotFoundHttpException(); + } + + return new JsonResponse([ + 'status' => 'pass', + 'version' => $this->appVersion ?? '', + 'notes' => [ + 'roadiz_version' => $this->cmsVersion ?? '', + 'roadiz_channel' => $this->cmsVersionPrefix ?? '', + ] + ], Response::HTTP_OK, [ + 'Content-type' => 'application/health+json', + 'Cache-Control' => 'public, max-age=10' + ]); + } +} diff --git a/lib/RoadizCoreBundle/src/Controller/RedirectionController.php b/lib/RoadizCoreBundle/src/Controller/RedirectionController.php new file mode 100644 index 00000000..90d1c8ec --- /dev/null +++ b/lib/RoadizCoreBundle/src/Controller/RedirectionController.php @@ -0,0 +1,98 @@ +urlGenerator = $urlGenerator; + } + + /** + * @param Request $request + * @param Redirection $redirection + * @return RedirectResponse + */ + public function redirectAction(Request $request, Redirection $redirection): RedirectResponse + { + if (null !== $redirection->getRedirectNodeSource()) { + return new RedirectResponse( + $this->urlGenerator->generate( + RouteObjectInterface::OBJECT_BASED_ROUTE_NAME, + [RouteObjectInterface::ROUTE_OBJECT => $redirection->getRedirectNodeSource()], + ), + $redirection->getType() + ); + } + + if ( + null !== $redirection->getRedirectUri() && + strlen($redirection->getRedirectUri()) > 0 + ) { + return new RedirectResponse($redirection->getRedirectUri(), $redirection->getType()); + } + + throw new ResourceNotFoundException(); + } + + /** + * Redirects to another route with the given name. + * + * The response status code is 302 if the permanent parameter is false (default), + * and 301 if the redirection is permanent. + * + * In case the route name is empty, the status code will be 404 when permanent is false + * and 410 otherwise. + * + * @param Request $request The request instance + * @param string $route The route name to redirect to + * @param bool $permanent Whether the redirection is permanent + * @param bool|array $ignoreAttributes Whether to ignore attributes or an array of attributes to ignore + * + * @return RedirectResponse A Response instance + * + * @throws HttpException In case the route name is empty + */ + public function redirectToRouteAction( + Request $request, + string $route, + bool $permanent = false, + $ignoreAttributes = false + ): RedirectResponse { + if ('' == $route) { + throw new HttpException($permanent ? 410 : 404); + } + $attributes = []; + if (false === $ignoreAttributes || is_array($ignoreAttributes)) { + $attributes = $request->attributes->get('_route_params'); + unset($attributes['route'], $attributes['permanent'], $attributes['ignoreAttributes']); + if ($ignoreAttributes) { + $attributes = array_diff_key($attributes, array_flip($ignoreAttributes)); + } + } + return new RedirectResponse( + $this->urlGenerator->generate( + $route, + $attributes, + UrlGeneratorInterface::ABSOLUTE_URL + ), + $permanent ? 301 : 302 + ); + } +} diff --git a/lib/RoadizCoreBundle/src/Crypto/UniqueKeyEncoderFactory.php b/lib/RoadizCoreBundle/src/Crypto/UniqueKeyEncoderFactory.php new file mode 100644 index 00000000..9747ed07 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Crypto/UniqueKeyEncoderFactory.php @@ -0,0 +1,55 @@ +keyChain = $keyChain; + $this->defaultKeyName = $defaultKeyName; + } + + public function getEncoder(?string $keyName = null): UniqueKeyEncoderInterface + { + try { + $keyName = $keyName ?? $this->defaultKeyName; + $key = $this->keyChain->get($keyName); + + if ($key instanceof EncryptionSecretKey) { + $publicKey = $key->derivePublicKey(); + return new AsymmetricUniqueKeyEncoder( + $publicKey, + $key + ); + } elseif ($key instanceof EncryptionKey) { + return new SymmetricUniqueKeyEncoder($key); + } + } catch (\Exception $exception) { + throw new InvalidKey( + sprintf('Key %s is not a valid encryption key', $keyName), + 0, + $exception + ); + } + + throw new InvalidKey(sprintf('Key %s is not a valid encryption key', $keyName)); + } +} diff --git a/lib/RoadizCoreBundle/src/CustomForm/CustomFormAnswerSerializer.php b/lib/RoadizCoreBundle/src/CustomForm/CustomFormAnswerSerializer.php new file mode 100644 index 00000000..0cc288ba --- /dev/null +++ b/lib/RoadizCoreBundle/src/CustomForm/CustomFormAnswerSerializer.php @@ -0,0 +1,50 @@ +urlGenerator = $urlGenerator; + } + + /** + * @param CustomFormAnswer $answer + * + * @return array + */ + public function toSimpleArray(CustomFormAnswer $answer): array + { + $answers = []; + /** @var CustomFormFieldAttribute $answerAttr */ + foreach ($answer->getAnswers() as $answerAttr) { + $field = $answerAttr->getCustomFormField(); + if ($field->isDocuments()) { + $answers[$field->getName()] = implode(PHP_EOL, $answerAttr->getDocuments()->map(function (Document $document) { + return $this->urlGenerator->generate('documentsDownloadPage', [ + 'documentId' => $document->getId() + ], UrlGeneratorInterface::ABSOLUTE_URL); + })->toArray()); + } else { + $answers[$field->getName()] = $answerAttr->getValue(); + } + } + return $answers; + } +} diff --git a/lib/RoadizCoreBundle/src/CustomForm/CustomFormHelper.php b/lib/RoadizCoreBundle/src/CustomForm/CustomFormHelper.php new file mode 100644 index 00000000..92051bd0 --- /dev/null +++ b/lib/RoadizCoreBundle/src/CustomForm/CustomFormHelper.php @@ -0,0 +1,240 @@ +em = $em; + $this->customForm = $customForm; + $this->documentFactory = $documentFactory; + $this->formFactory = $formFactory; + $this->settingsBag = $settingsBag; + $this->eventDispatcher = $eventDispatcher; + } + + /** + * @param Request $request + * @param boolean $forceExpanded + * @param bool $prefix + * @return FormInterface + */ + public function getForm( + Request $request, + bool $forceExpanded = false, + bool $prefix = true + ): FormInterface { + $defaults = $request->query->all(); + if ($prefix) { + $name = (new AsciiSlugger())->slug($this->customForm->getName())->snake()->toString(); + } else { + $name = ''; + } + return $this->formFactory->createNamed($name, CustomFormsType::class, $defaults, [ + 'customForm' => $this->customForm, + 'forceExpanded' => $forceExpanded, + ]); + } + + /** + * Create or update custom-form answer and its attributes from + * a submitted form data. + * + * @param FormInterface $form + * @param CustomFormAnswer|null $answer + * @param string $ipAddress + * @return CustomFormAnswer + * @throws FilesystemException + */ + public function parseAnswerFormData( + FormInterface $form, + CustomFormAnswer $answer = null, + string $ipAddress = "" + ): CustomFormAnswer { + if ($form->isSubmitted() && $form->isValid()) { + /* + * Create answer if null. + */ + if (null === $answer) { + $answer = new CustomFormAnswer(); + $answer->setCustomForm($this->customForm); + $this->em->persist($answer); + } + $answer->setSubmittedAt(new \DateTime()); + $answer->setIp($ipAddress); + $documentsUploaded = []; + + /** @var CustomFormField $customFormField */ + foreach ($this->customForm->getFields() as $customFormField) { + $formField = null; + $fieldAttr = null; + + /* + * Get data in form groups + */ + if ($customFormField->getGroupName() != '') { + $groupCanonical = StringHandler::slugify($customFormField->getGroupName()); + $formGroup = $form->get($groupCanonical); + if ($formGroup->has($customFormField->getName())) { + $formField = $formGroup->get($customFormField->getName()); + $fieldAttr = $this->getAttribute($answer, $customFormField); + } + } else { + if ($form->has($customFormField->getName())) { + $formField = $form->get($customFormField->getName()); + $fieldAttr = $this->getAttribute($answer, $customFormField); + } + } + + if (null !== $formField) { + $data = $formField->getData(); + /* + * Create attribute if null. + */ + if (null === $fieldAttr) { + $fieldAttr = new CustomFormFieldAttribute(); + $fieldAttr->setCustomFormAnswer($answer); + $fieldAttr->setCustomFormField($customFormField); + $this->em->persist($fieldAttr); + } + + if (is_array($data) && isset($data[0]) && $data[0] instanceof UploadedFile) { + /** @var UploadedFile $file */ + foreach ($data as $file) { + $documentsUploaded[] = $this->handleUploadedFile($file, $fieldAttr); + } + } elseif ($data instanceof UploadedFile) { + $documentsUploaded[] = $this->handleUploadedFile($data, $fieldAttr); + } else { + $fieldAttr->setValue($this->formValueToString($data)); + } + } + } + + $this->em->flush(); + + // Dispatch event on document uploaded + foreach ($documentsUploaded as $documentUploaded) { + if ($documentUploaded instanceof DocumentInterface) { + $this->eventDispatcher->dispatch(new DocumentCreatedEvent($documentUploaded)); + } + } + + $this->em->refresh($answer); + + return $answer; + } + + throw new \InvalidArgumentException('Form must be submitted and validated before begin parsing.'); + } + + /** + * @param UploadedFile $file + * @param CustomFormFieldAttribute $fieldAttr + * @return DocumentInterface|null + * @throws FilesystemException + */ + protected function handleUploadedFile( + UploadedFile $file, + CustomFormFieldAttribute $fieldAttr + ): ?DocumentInterface { + $this->documentFactory->setFile($file); + $this->documentFactory->setFolder($this->getDocumentFolderForCustomForm()); + $document = $this->documentFactory->getDocument(); + if (null !== $document) { + $fieldAttr->getDocuments()->add($document); + $fieldAttr->setValue($fieldAttr->getValue() . ', ' . $file->getPathname()); + } + return $document; + } + + /** + * @return Folder|null + */ + protected function getDocumentFolderForCustomForm(): ?Folder + { + return $this->em->getRepository(Folder::class) + ->findOrCreateByPath( + 'custom_forms/' . + $this->customForm->getCreatedAt()->format('Ymd') . '_' . + substr($this->customForm->getDisplayName(), 0, 30) + ); + } + + /** + * @param mixed $rawValue + * @return string + */ + private function formValueToString($rawValue): string + { + if ($rawValue instanceof \DateTime) { + return $rawValue->format('Y-m-d H:i:s'); + } elseif (is_array($rawValue)) { + $values = $rawValue; + $values = array_map('trim', $values); + $values = array_map('strip_tags', $values); + return implode(static::ARRAY_SEPARATOR, $values); + } else { + return strip_tags((string) $rawValue); + } + } + + /** + * @param CustomFormAnswer $answer + * @param CustomFormField $field + * @return CustomFormFieldAttribute|null + */ + private function getAttribute(CustomFormAnswer $answer, CustomFormField $field): ?CustomFormFieldAttribute + { + return $this->em->getRepository(CustomFormFieldAttribute::class)->findOneBy([ + 'customFormAnswer' => $answer, + 'customFormField' => $field, + ]); + } +} diff --git a/lib/RoadizCoreBundle/src/CustomForm/CustomFormHelperFactory.php b/lib/RoadizCoreBundle/src/CustomForm/CustomFormHelperFactory.php new file mode 100644 index 00000000..602c7133 --- /dev/null +++ b/lib/RoadizCoreBundle/src/CustomForm/CustomFormHelperFactory.php @@ -0,0 +1,54 @@ +privateDocumentFactory = $privateDocumentFactory; + $this->em = $em; + $this->formFactory = $formFactory; + $this->settingsBag = $settingsBag; + $this->eventDispatcher = $eventDispatcher; + } + + public function createHelper(CustomForm $customForm): CustomFormHelper + { + return new CustomFormHelper( + $this->em, + $customForm, + $this->privateDocumentFactory, + $this->formFactory, + $this->settingsBag, + $this->eventDispatcher + ); + } +} diff --git a/lib/RoadizCoreBundle/src/DependencyInjection/Compiler/CommonMarkCompilerPass.php b/lib/RoadizCoreBundle/src/DependencyInjection/Compiler/CommonMarkCompilerPass.php new file mode 100644 index 00000000..fc540e8c --- /dev/null +++ b/lib/RoadizCoreBundle/src/DependencyInjection/Compiler/CommonMarkCompilerPass.php @@ -0,0 +1,63 @@ +has('roadiz_core.markdown.environments.text_converter')) { + $definition = $container->findDefinition( + 'roadiz_core.markdown.environments.text_converter' + ); + $taggedServices = $container->findTaggedServiceIds( + 'roadiz_core.markdown.text_converter.extension' + ); + foreach ($taggedServices as $id => $tags) { + $definition->addMethodCall( + 'addExtension', + [new Reference($id)] + ); + } + } + + if ($container->has('roadiz_core.markdown.environments.text_extra_converter')) { + $definition = $container->findDefinition( + 'roadiz_core.markdown.environments.text_extra_converter' + ); + $taggedServices = $container->findTaggedServiceIds( + 'roadiz_core.markdown.text_extra_converter.extension' + ); + foreach ($taggedServices as $id => $tags) { + $definition->addMethodCall( + 'addExtension', + array(new Reference($id)) + ); + } + } + + if ($container->has('roadiz_core.markdown.environments.line_converter')) { + $definition = $container->findDefinition( + 'roadiz_core.markdown.environments.line_converter' + ); + $taggedServices = $container->findTaggedServiceIds( + 'roadiz_core.markdown.line_converter.extension' + ); + foreach ($taggedServices as $id => $tags) { + $definition->addMethodCall( + 'addExtension', + array(new Reference($id)) + ); + } + } + } +} diff --git a/lib/RoadizCoreBundle/src/DependencyInjection/Compiler/DoctrineMigrationCompilerPass.php b/lib/RoadizCoreBundle/src/DependencyInjection/Compiler/DoctrineMigrationCompilerPass.php new file mode 100644 index 00000000..d5fc82d2 --- /dev/null +++ b/lib/RoadizCoreBundle/src/DependencyInjection/Compiler/DoctrineMigrationCompilerPass.php @@ -0,0 +1,55 @@ +hasDefinition('doctrine.migrations.configuration')) { + $configurationDefinition = $container->getDefinition('doctrine.migrations.configuration'); + $ns = 'RZ\Roadiz\Migrations'; + $path = '@RoadizCoreBundle/migrations'; + + $path = $this->checkIfBundleRelativePath($path, $container); + $configurationDefinition->addMethodCall('addMigrationsDirectory', [$ns, $path]); + } + } + + private function checkIfBundleRelativePath(string $path, ContainerBuilder $container): string + { + if (isset($path[0]) && $path[0] === '@') { + $pathParts = explode('/', $path); + $bundleName = substr($pathParts[0], 1); + $bundlePath = $this->getBundlePath($bundleName, $container); + return $bundlePath . substr($path, strlen('@' . $bundleName)); + } + + return $path; + } + + private function getBundlePath(string $bundleName, ContainerBuilder $container): string + { + $bundleMetadata = $container->getParameter('kernel.bundles_metadata'); + assert(is_array($bundleMetadata)); + + if (! isset($bundleMetadata[$bundleName])) { + throw new RuntimeException(sprintf( + 'The bundle "%s" has not been registered, available bundles: %s', + $bundleName, + implode(', ', array_keys($bundleMetadata)) + )); + } + + return $bundleMetadata[$bundleName]['path']; + } +} diff --git a/lib/RoadizCoreBundle/src/DependencyInjection/Compiler/DocumentRendererCompilerPass.php b/lib/RoadizCoreBundle/src/DependencyInjection/Compiler/DocumentRendererCompilerPass.php new file mode 100644 index 00000000..80542056 --- /dev/null +++ b/lib/RoadizCoreBundle/src/DependencyInjection/Compiler/DocumentRendererCompilerPass.php @@ -0,0 +1,35 @@ +has(ChainRenderer::class)) { + $definition = $container->findDefinition(ChainRenderer::class); + $references = $this->findAndSortTaggedServices( + 'roadiz_core.document_renderer', + $container + ); + foreach ($references as $reference) { + $definition->addMethodCall( + 'addRenderer', + [$reference] + ); + } + } + } +} diff --git a/lib/RoadizCoreBundle/src/DependencyInjection/Compiler/FlysystemStorageCompilerPass.php b/lib/RoadizCoreBundle/src/DependencyInjection/Compiler/FlysystemStorageCompilerPass.php new file mode 100644 index 00000000..b4f53aae --- /dev/null +++ b/lib/RoadizCoreBundle/src/DependencyInjection/Compiler/FlysystemStorageCompilerPass.php @@ -0,0 +1,93 @@ +hasDefinition($storageName)) { + $definitionFactory = new AdapterDefinitionFactory(); + $adapterName = 'flysystem.adapter.' . $storageName; + if ($adapter = $definitionFactory->createDefinition('local', $storageConfig['options'])) { + $container->setDefinition($adapterName, $adapter)->setPublic(false); + $container->setDefinition( + $storageName, + $this->createStorageDefinition($storageName, new Reference($adapterName), $publicUrl) + ); + } + } + return new Reference($storageName); + } + + protected function getDocumentPublicStorage(ContainerBuilder $container): Reference + { + return $this->getStorageReference( + $container, + 'documents_public.storage', + ['options' => ['directory' => '%kernel.project_dir%/public/files']], + '/files/' + ); + } + + protected function getDocumentPrivateStorage(ContainerBuilder $container): Reference + { + return $this->getStorageReference( + $container, + 'documents_private.storage', + ['options' => ['directory' => '%kernel.project_dir%/var/files/private']] + ); + } + + /** + * @inheritDoc + */ + public function process(ContainerBuilder $container): void + { + $container->setDefinition( + 'roadiz_core.flysystem.mount_manager', + (new Definition()) + ->setClass(MountManager::class) + ->setArguments([[ + 'public' => $this->getDocumentPublicStorage($container), + 'private' => $this->getDocumentPrivateStorage($container), + ]]) + ->addTag('flysystem.storage', ['storage' => 'documents.storage']) + ); + + $container->setAlias('documents.storage', 'roadiz_core.flysystem.mount_manager'); + $container->setAlias(FilesystemOperator::class, 'roadiz_core.flysystem.mount_manager'); + } + + private function createStorageDefinition(string $storageName, Reference $adapter, ?string $publicUrl = null): Definition + { + $definition = new Definition(Filesystem::class); + $definition->setPublic(false); + $definition->setArgument(0, $adapter); + $definition->setArgument(1, [ + 'public_url' => $publicUrl, + 'visibility' => null, + 'directory_visibility' => null, + 'case_sensitive' => true, + 'disable_asserts' => false, + ]); + $definition->addTag('flysystem.storage', ['storage' => $storageName]); + + return $definition; + } +} diff --git a/lib/RoadizCoreBundle/src/DependencyInjection/Compiler/ImporterCompilerPass.php b/lib/RoadizCoreBundle/src/DependencyInjection/Compiler/ImporterCompilerPass.php new file mode 100644 index 00000000..b2de17cc --- /dev/null +++ b/lib/RoadizCoreBundle/src/DependencyInjection/Compiler/ImporterCompilerPass.php @@ -0,0 +1,32 @@ +has(ChainImporter::class)) { + $definition = $container->findDefinition(ChainImporter::class); + $taggedServices = $container->findTaggedServiceIds( + 'roadiz_core.importer' + ); + foreach ($taggedServices as $id => $tags) { + $definition->addMethodCall( + 'addImporter', + [new Reference($id)] + ); + } + } + } +} diff --git a/lib/RoadizCoreBundle/src/DependencyInjection/Compiler/MediaFinderCompilerPass.php b/lib/RoadizCoreBundle/src/DependencyInjection/Compiler/MediaFinderCompilerPass.php new file mode 100644 index 00000000..b6deacab --- /dev/null +++ b/lib/RoadizCoreBundle/src/DependencyInjection/Compiler/MediaFinderCompilerPass.php @@ -0,0 +1,33 @@ +hasParameter('roadiz_core.medias.supported_platforms')) { + $parameter = $container->getParameter('roadiz_core.medias.supported_platforms'); + $taggedServices = $container->findTaggedServiceIds( + 'roadiz_core.media_finder' + ); + foreach ($taggedServices as $id => $tags) { + foreach ($tags as $tag) { + if (isset($tag['platform'])) { + $parameter[$tag['platform']] = $id; + } + } + } + ksort($parameter); + $container->setParameter('roadiz_core.medias.supported_platforms', $parameter); + } + } +} diff --git a/lib/RoadizCoreBundle/src/DependencyInjection/Compiler/NodeWorkflowCompilerPass.php b/lib/RoadizCoreBundle/src/DependencyInjection/Compiler/NodeWorkflowCompilerPass.php new file mode 100644 index 00000000..e2216544 --- /dev/null +++ b/lib/RoadizCoreBundle/src/DependencyInjection/Compiler/NodeWorkflowCompilerPass.php @@ -0,0 +1,30 @@ +hasDefinition('workflow.registry')) { + throw new LogicException('Workflow support cannot be enabled as the Workflow component is not installed. Try running "composer require symfony/workflow".'); + } + + $workflowId = 'state_machine.node'; + $registryDefinition = $container->getDefinition('workflow.registry'); + + $strategyDefinition = new Definition(InstanceOfSupportStrategy::class, [Node::class]); + $strategyDefinition->setPublic(false); + $registryDefinition->addMethodCall('addWorkflow', [new Reference($workflowId), $strategyDefinition]); + } +} diff --git a/lib/RoadizCoreBundle/src/DependencyInjection/Compiler/NodesSourcesEntitiesPathCompilerPass.php b/lib/RoadizCoreBundle/src/DependencyInjection/Compiler/NodesSourcesEntitiesPathCompilerPass.php new file mode 100644 index 00000000..d771a7ca --- /dev/null +++ b/lib/RoadizCoreBundle/src/DependencyInjection/Compiler/NodesSourcesEntitiesPathCompilerPass.php @@ -0,0 +1,22 @@ +getParameter('kernel.project_dir'); + $container->setParameter('roadiz_core.generated_entities_dir', $projectDir . '/src/GeneratedEntity'); + $container->setParameter('roadiz_core.serialized_node_types_dir', $projectDir . '/src/Resources/node-types'); + $container->setParameter('roadiz_core.import_files_config_path', $projectDir . '/src/Resources/config.yml'); + } +} diff --git a/lib/RoadizCoreBundle/src/DependencyInjection/Compiler/PathResolverCompilerPass.php b/lib/RoadizCoreBundle/src/DependencyInjection/Compiler/PathResolverCompilerPass.php new file mode 100644 index 00000000..aff479f2 --- /dev/null +++ b/lib/RoadizCoreBundle/src/DependencyInjection/Compiler/PathResolverCompilerPass.php @@ -0,0 +1,35 @@ +has(ChainResourcePathResolver::class)) { + $definition = $container->findDefinition(ChainResourcePathResolver::class); + $references = $this->findAndSortTaggedServices( + 'roadiz_core.path_resolver', + $container + ); + foreach ($references as $reference) { + $definition->addMethodCall( + 'addPathResolver', + [$reference] + ); + } + } + } +} diff --git a/lib/RoadizCoreBundle/src/DependencyInjection/Compiler/RateLimitersCompilerPass.php b/lib/RoadizCoreBundle/src/DependencyInjection/Compiler/RateLimitersCompilerPass.php new file mode 100644 index 00000000..d619971c --- /dev/null +++ b/lib/RoadizCoreBundle/src/DependencyInjection/Compiler/RateLimitersCompilerPass.php @@ -0,0 +1,46 @@ +hasDefinition('limiter')) { + throw new LogicException('Rate limiter support cannot be enabled as the RateLimiter component is not installed. Try running "composer require symfony/rate-limiter".'); + } + + // default configuration (when used by other DI extensions) + $name = 'throttled_webhooks'; + $limiterConfig = [ + 'cache_pool' => 'cache.rate_limiter', + 'policy' => 'token_bucket', + 'limit' => 1, + 'rate' => [ 'interval' => '10 seconds'], + ]; + $limiter = $container->setDefinition( + $limiterId = 'limiter.' . $name, + new ChildDefinition('limiter') + ); + $container->register( + $storageId = 'limiter.storage.' . $name, + CacheStorage::class + )->addArgument(new Reference($limiterConfig['cache_pool'])); + + $limiter->replaceArgument(1, new Reference($storageId)); + unset($limiterConfig['cache_pool']); + $limiterConfig['id'] = $name; + $limiter->replaceArgument(0, $limiterConfig); + $container->registerAliasForArgument($limiterId, RateLimiterFactory::class, $name . '.limiter'); + } +} diff --git a/lib/RoadizCoreBundle/src/DependencyInjection/Compiler/TwigLoaderCompilerPass.php b/lib/RoadizCoreBundle/src/DependencyInjection/Compiler/TwigLoaderCompilerPass.php new file mode 100644 index 00000000..a59480e1 --- /dev/null +++ b/lib/RoadizCoreBundle/src/DependencyInjection/Compiler/TwigLoaderCompilerPass.php @@ -0,0 +1,28 @@ +has('twig.loader.native_filesystem')) { + $definition = $container->findDefinition('twig.loader.native_filesystem'); + $definition->addMethodCall( + 'prependPath', + [ realpath(dirname(__DIR__) . '/../../templates/ApiPlatformBundle'), 'ApiPlatform' ] + ); + } + } +} diff --git a/lib/RoadizCoreBundle/src/DependencyInjection/Configuration.php b/lib/RoadizCoreBundle/src/DependencyInjection/Configuration.php new file mode 100644 index 00000000..5529c8d4 --- /dev/null +++ b/lib/RoadizCoreBundle/src/DependencyInjection/Configuration.php @@ -0,0 +1,218 @@ +getRootNode(); + + $root->addDefaultsIfNotSet() + ->children() + ->scalarNode('appNamespace') + ->defaultValue('roadiz_app') + ->end() + ->scalarNode('healthCheckToken') + ->defaultNull() + ->end() + ->scalarNode('appVersion') + ->defaultValue('0.1.0') + ->end() + ->scalarNode('staticDomainName') + ->defaultNull() + ->end() + ->scalarNode('maxVersionsShowed') + ->defaultValue(10) + ->end() + ->scalarNode('defaultNodeSourceController') + ->defaultValue(DefaultNodeSourceController::class) + ->end() + ->booleanNode('useNativeJsonColumnType') + ->defaultValue(true) + ->end() + ->booleanNode('hideRoadizVersion') + ->defaultValue(false) + ->end() + ->scalarNode('documentsLibDir')->defaultValue( + 'vendor/roadiz/documents/src' + )->info('Relative path to Roadiz Documents lib sources from project directory.')->end() + ->booleanNode('useAcceptLanguageHeader') + ->defaultValue(false) + ->info(<<end() + ->booleanNode('useTypedNodeNames') + ->defaultValue(true) + ->info(<<end() + ->arrayNode('security') + ->addDefaultsIfNotSet() + ->children() + ->scalarNode('private_key_dir') + ->defaultValue('%kernel.project_dir%/var/secret') + ->info('Asymmetric cryptographic key directory.') + ->end() + ->scalarNode('private_key_name') + ->defaultValue('default') + ->info('Asymmetric cryptographic key name.') + ->end() + ->end() + ->end() + ->append($this->addSolrNode()) + ->append($this->addInheritanceNode()) + ->append($this->addReverseProxyCacheNode()) + ->append($this->addMediasNode()) + ; + return $builder; + } + + /** + * @return ArrayNodeDefinition|NodeDefinition + */ + protected function addInheritanceNode() + { + $builder = new TreeBuilder('inheritance'); + $node = $builder->getRootNode(); + $node->addDefaultsIfNotSet() + ->children() + ->scalarNode('type') + ->defaultValue(static::INHERITANCE_TYPE_SINGLE_TABLE) + ->info(<<validate() + ->ifNotInArray([ + static::INHERITANCE_TYPE_JOINED, + static::INHERITANCE_TYPE_SINGLE_TABLE + ]) + ->thenInvalid('The %s inheritance type is not supported ("joined", "single_table" are accepted).') + ->end() + ->end() + ; + return $node; + } + + /** + * @return ArrayNodeDefinition|NodeDefinition + */ + protected function addMediasNode() + { + $builder = new TreeBuilder('medias'); + $node = $builder->getRootNode(); + $node->addDefaultsIfNotSet() + ->children() + ->scalarNode('unsplash_client_id')->defaultNull()->end() + ->scalarNode('google_server_id')->defaultNull()->end() + ->scalarNode('soundcloud_client_id')->defaultNull()->end() + ->scalarNode('recaptcha_private_key')->defaultNull()->end() + ->scalarNode('recaptcha_public_key')->defaultNull()->end() + ->scalarNode('ffmpeg_path')->defaultNull()->end() + ->end(); + + return $node; + } + + /** + * @return ArrayNodeDefinition|NodeDefinition + */ + protected function addSolrNode() + { + $builder = new TreeBuilder('solr'); + $node = $builder->getRootNode(); + + $node->addDefaultsIfNotSet() + ->children() + ->scalarNode('timeout')->defaultValue(3)->end() + ->arrayNode('endpoints') + ->useAttributeAsKey('name') + ->prototype('array') + ->children() + ->scalarNode('host')->defaultValue('127.0.0.1')->end() + ->scalarNode('username')->end() + ->scalarNode('password')->end() + ->scalarNode('core')->isRequired()->end() + ->enumNode('scheme') + ->values(['http', 'https']) + ->defaultValue('http') + ->end() + ->scalarNode('port')->defaultValue(8983)->end() + ->scalarNode('path')->defaultValue('/')->end() + ->end() + ->end() + ->end() + ->end(); + + return $node; + } + + /** + * @return ArrayNodeDefinition|NodeDefinition + */ + protected function addReverseProxyCacheNode() + { + $builder = new TreeBuilder('reverseProxyCache'); + $node = $builder->getRootNode(); + $node->children() + ->arrayNode('frontend') + ->isRequired() + ->useAttributeAsKey('name') + ->prototype('array') + ->children() + ->scalarNode('host') + ->isRequired() + ->defaultValue('localhost') + ->end() + ->scalarNode('domainName') + ->isRequired() + ->defaultValue('localhost') + ->end() + ->scalarNode('timeout')->defaultValue(3)->end() + ->end() + ->end() + ->end() + ->arrayNode('cloudflare') + ->addDefaultsIfNotSet() + ->children() + ->scalarNode('version') + ->defaultValue('v4') + ->end() + ->scalarNode('zone') + ->isRequired() + ->end() + ->scalarNode('bearer')->end() + ->scalarNode('email')->end() + ->scalarNode('key')->end() + ->scalarNode('timeout') + ->defaultValue(3) + ->end() + ->end() + ->end() + ->end(); + + return $node; + } +} diff --git a/lib/RoadizCoreBundle/src/DependencyInjection/RoadizCoreExtension.php b/lib/RoadizCoreBundle/src/DependencyInjection/RoadizCoreExtension.php new file mode 100644 index 00000000..fc150208 --- /dev/null +++ b/lib/RoadizCoreBundle/src/DependencyInjection/RoadizCoreExtension.php @@ -0,0 +1,387 @@ +load('services.yaml'); + + $configuration = new Configuration(); + $config = $this->processConfiguration($configuration, $configs); + + $container->setParameter('roadiz_core.app_namespace', $config['appNamespace']); + $container->setParameter('roadiz_core.app_version', $config['appVersion']); + $container->setParameter('roadiz_core.health_check_token', $config['healthCheckToken']); + $container->setParameter('roadiz_core.inheritance_type', $config['inheritance']['type']); + $container->setParameter('roadiz_core.max_versions_showed', $config['maxVersionsShowed']); + $container->setParameter('roadiz_core.static_domain_name', $config['staticDomainName'] ?? ''); + $container->setParameter('roadiz_core.private_key_name', $config['security']['private_key_name']); + $container->setParameter('roadiz_core.private_key_dir', $config['security']['private_key_dir']); + $container->setParameter( + 'roadiz_core.private_key_path', + $config['security']['private_key_dir'] . DIRECTORY_SEPARATOR . $config['security']['private_key_name'] + ); + $container->setParameter('roadiz_core.default_node_source_controller', $config['defaultNodeSourceController']); + $container->setParameter('roadiz_core.use_native_json_column_type', $config['useNativeJsonColumnType']); + $container->setParameter('roadiz_core.use_typed_node_names', $config['useTypedNodeNames']); + $container->setParameter('roadiz_core.hide_roadiz_version', $config['hideRoadizVersion']); + $container->setParameter('roadiz_core.use_accept_language_header', $config['useAcceptLanguageHeader']); + + /* + * Assets config + */ + if (extension_loaded('gd')) { + $gd_infos = gd_info(); + $container->setParameter('roadiz_core.assets_processing.supports_webp', (bool) $gd_infos['WebP Support']); + } else { + $container->setParameter('roadiz_core.assets_processing.supports_webp', false); + } + + $container->setParameter( + 'roadiz_core.documents_lib_dir', + $container->getParameter('kernel.project_dir') . DIRECTORY_SEPARATOR . trim($config['documentsLibDir'], "/ \t\n\r\0\x0B") + ); + /* + * Media config + */ + $container->setParameter( + 'roadiz_core.medias.ffmpeg_path', + $config['medias']['ffmpeg_path'] ?? null + ); + $container->setParameter( + 'roadiz_core.medias.unsplash_client_id', + $config['medias']['unsplash_client_id'] ?? '' + ); + $container->setParameter( + 'roadiz_core.medias.google_server_id', + $config['medias']['google_server_id'] ?? null + ); + $container->setParameter( + 'roadiz_core.medias.soundcloud_client_id', + $config['medias']['soundcloud_client_id'] ?? null + ); + $container->setParameter( + 'roadiz_core.medias.recaptcha_private_key', + $config['medias']['recaptcha_private_key'] ?? null + ); + $container->setParameter( + 'roadiz_core.medias.recaptcha_public_key', + $config['medias']['recaptcha_public_key'] ?? null + ); + $container->setParameter('roadiz_core.medias.supported_platforms', []); + + $container->setParameter('roadiz_core.webhook.message_types', [ + 'webhook.type.generic_json_post' => GenericJsonPostMessage::class, + 'webhook.type.gitlab_pipeline' => GitlabPipelineTriggerMessage::class, + 'webhook.type.netlify_build_hook' => NetlifyBuildHookMessage::class, + ]); + + $this->registerEntityGenerator($config, $container); + $this->registerReverseProxyCache($config, $container); + $this->registerSolr($config, $container); + $this->registerMarkdown($config, $container); + $this->registerCrypto($config, $container); + } + + private function registerCrypto(array $config, ContainerBuilder $container): void + { + $container->setDefinition( + UniqueKeyEncoderFactory::class, + (new Definition()) + ->setClass(UniqueKeyEncoderFactory::class) + ->setPublic(true) + ->setArguments([ + new Reference(KeyChainInterface::class), + $container->getParameter('roadiz_core.private_key_name') + ]) + ); + + $container->setDefinition( + KeyChainInterface::class, + (new Definition()) + ->setClass(AsymmetricFilesystemKeyChain::class) + ->setPublic(true) + ->setArguments([ + $container->getParameter('roadiz_core.private_key_dir') + ]) + ); + } + + private function registerReverseProxyCache(array $config, ContainerBuilder $container): void + { + $reverseProxyCacheFrontendsReferences = []; + if (isset($config['reverseProxyCache'])) { + foreach ($config['reverseProxyCache']['frontend'] as $name => $frontend) { + $definitionName = 'roadiz_core.reverse_proxy_cache.frontends.' . $name; + $container->setDefinition( + $definitionName, + (new Definition()) + ->setClass(ReverseProxyCache::class) + ->setPublic(true) + ->setArguments([ + $name, + $frontend['host'], + $frontend['domainName'], + $frontend['timeout'], + ]) + ); + $reverseProxyCacheFrontendsReferences[] = new Reference($definitionName); + } + + if ( + isset($config['reverseProxyCache']['cloudflare']) && + isset($config['reverseProxyCache']['cloudflare']['bearer']) + ) { + $container->setDefinition( + 'roadiz_core.reverse_proxy_cache.cloudflare', + (new Definition()) + ->setClass(CloudflareProxyCache::class) + ->setPublic(true) + ->setArguments([ + 'cloudflare', + $config['reverseProxyCache']['cloudflare']['zone'], + $config['reverseProxyCache']['cloudflare']['version'], + $config['reverseProxyCache']['cloudflare']['bearer'], + $config['reverseProxyCache']['cloudflare']['email'], + $config['reverseProxyCache']['cloudflare']['key'], + $config['reverseProxyCache']['cloudflare']['timeout'], + ]) + ); + } + } + + $container->setDefinition( + ReverseProxyCacheLocator::class, + (new Definition()) + ->setClass(ReverseProxyCacheLocator::class) + ->setPublic(true) + ->setArguments([ + $reverseProxyCacheFrontendsReferences, + new Reference( + 'roadiz_core.reverse_proxy_cache.cloudflare', + ContainerInterface::NULL_ON_INVALID_REFERENCE + ) + ]) + ); + } + + private function registerEntityGenerator(array $config, ContainerBuilder $container): void + { + $entityGeneratorFactoryOptions = [ + 'parent_class' => NodesSources::class, + 'repository_class' => NodesSourcesRepository::class, + 'node_class' => Node::class, + 'document_class' => Document::class, + 'document_proxy_class' => NodesSourcesDocuments::class, + 'custom_form_class' => CustomForm::class, + 'custom_form_proxy_class' => NodesCustomForms::class, + 'translation_class' => Translation::class, + 'namespace' => NodeType::getGeneratedEntitiesNamespace(), + 'use_native_json' => $config['useNativeJsonColumnType'], + 'use_api_platform_filters' => true, + ]; + $container->setParameter('roadiz_core.entity_generator_factory.options', $entityGeneratorFactoryOptions); + } + + private function registerSolr(array $config, ContainerBuilder $container): void + { + $solrEndpoints = []; + $container->setDefinition( + 'roadiz_core.solr.adapter', + (new Definition()) + ->setClass(Curl::class) + ->setPublic(true) + ->addMethodCall('setTimeout', [$config['solr']['timeout']]) + ->addMethodCall('setConnectionTimeout', [$config['solr']['timeout']]) + ); + if (isset($config['solr'])) { + foreach ($config['solr']['endpoints'] as $name => $endpoint) { + $container->setDefinition( + 'roadiz_core.solr.endpoints.' . $name, + (new Definition()) + ->setClass(Endpoint::class) + ->setPublic(true) + ->setArguments([ + $endpoint + ]) + ->addMethodCall('setKey', [$name]) + ); + $solrEndpoints[] = 'roadiz_core.solr.endpoints.' . $name; + } + } + if (count($solrEndpoints) > 0) { + $container->setDefinition( + 'roadiz_core.solr.client', + (new Definition()) + ->setClass(Client::class) + ->setLazy(true) + ->setPublic(true) + ->setShared(true) + ->setArguments([ + new Reference('roadiz_core.solr.adapter'), + new Reference(EventDispatcherInterface::class) + ]) + ->addMethodCall('setEndpoints', [array_map(function (string $endpointId) { + return new Reference($endpointId); + }, $solrEndpoints)]) + ); + } + $container->setParameter('roadiz_core.solr.clients', $solrEndpoints); + } + + private function registerMarkdown(array $config, ContainerBuilder $container): void + { + $container->setParameter('roadiz_core.markdown_config_default', [ + 'external_link' => [ + 'open_in_new_window' => true, + 'noopener' => 'external', + 'noreferrer' => 'external', + ] + ]); + $container->setParameter( + 'roadiz_core.markdown_config_text_converter', + array_merge($container->getParameter('roadiz_core.markdown_config_default'), [ + 'html_input' => 'allow' + ]) + ); + $container->setParameter( + 'roadiz_core.markdown_config_text_extra_converter', + array_merge($container->getParameter('roadiz_core.markdown_config_default'), [ + 'html_input' => 'allow' + ]) + ); + $container->setParameter( + 'roadiz_core.markdown_config_line_converter', + array_merge($container->getParameter('roadiz_core.markdown_config_default'), [ + 'html_input' => 'escape' + ]) + ); + + $container->setDefinition( + 'roadiz_core.markdown.environments.text_converter', + (new Definition()) + ->setClass(Environment::class) + ->setShared(true) + ->setPublic(true) + ->setArguments([ + '%roadiz_core.markdown_config_text_converter%', + ]) + ); + + $container->setDefinition( + 'roadiz_core.markdown.converters.text_converter', + (new Definition()) + ->setClass(MarkdownConverter::class) + ->setShared(true) + ->setPublic(true) + ->setArguments([ + new Reference('roadiz_core.markdown.environments.text_converter') + ]) + ); + + $container->setDefinition( + 'roadiz_core.markdown.environments.text_extra_converter', + (new Definition()) + ->setClass(Environment::class) + ->setShared(true) + ->setPublic(true) + ->setArguments([ + '%roadiz_core.markdown_config_text_extra_converter%', + ]) + ); + + $container->setDefinition( + 'roadiz_core.markdown.converters.text_extra_converter', + (new Definition()) + ->setClass(MarkdownConverter::class) + ->setShared(true) + ->setPublic(true) + ->setArguments([ + new Reference('roadiz_core.markdown.environments.text_extra_converter') + ]) + ); + + $container->setDefinition( + 'roadiz_core.markdown.environments.line_converter', + (new Definition()) + ->setClass(Environment::class) + ->setShared(true) + ->setPublic(true) + ->setArguments([ + '%roadiz_core.markdown_config_line_converter%', + ]) + ); + + $container->setDefinition( + 'roadiz_core.markdown.converters.line_converter', + (new Definition()) + ->setClass(MarkdownConverter::class) + ->setShared(true) + ->setPublic(true) + ->setArguments([ + new Reference('roadiz_core.markdown.environments.line_converter') + ]) + ); + + $container->setDefinition( + MarkdownInterface::class, + (new Definition()) + ->setClass(CommonMark::class) + ->setShared(true) + ->setArguments([ + new Reference('roadiz_core.markdown.converters.text_converter'), + new Reference('roadiz_core.markdown.converters.text_extra_converter'), + new Reference('roadiz_core.markdown.converters.line_converter'), + new Reference(Stopwatch::class), + ]) + ); + } +} diff --git a/lib/RoadizCoreBundle/src/Doctrine/Event/FilterNodesSourcesQueryBuilderCriteriaEvent.php b/lib/RoadizCoreBundle/src/Doctrine/Event/FilterNodesSourcesQueryBuilderCriteriaEvent.php new file mode 100644 index 00000000..ee8da9b0 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Doctrine/Event/FilterNodesSourcesQueryBuilderCriteriaEvent.php @@ -0,0 +1,40 @@ +actualEntityName === NodesSources::class) { + return true; + } + + $reflectionClass = new \ReflectionClass($this->actualEntityName); + if ($reflectionClass->isSubclassOf(NodesSources::class)) { + return true; + } + + return false; + } +} diff --git a/lib/RoadizCoreBundle/src/Doctrine/Event/FilterQueryBuilderCriteriaEvent.php b/lib/RoadizCoreBundle/src/Doctrine/Event/FilterQueryBuilderCriteriaEvent.php new file mode 100644 index 00000000..974ff248 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Doctrine/Event/FilterQueryBuilderCriteriaEvent.php @@ -0,0 +1,95 @@ +queryBuilder = $queryBuilder; + $this->entityClass = $entityClass; + $this->property = $property; + $this->value = $value; + $this->actualEntityName = $actualEntityName; + } + + /** + * @return QueryBuilder + */ + public function getQueryBuilder(): QueryBuilder + { + return $this->queryBuilder; + } + + /** + * @param QueryBuilder $queryBuilder + * @return FilterQueryBuilderCriteriaEvent + */ + public function setQueryBuilder(QueryBuilder $queryBuilder): self + { + $this->queryBuilder = $queryBuilder; + return $this; + } + + /** + * @return string + */ + public function getProperty() + { + return $this->property; + } + + /** + * @return mixed + */ + public function getValue() + { + return $this->value; + } + + /** + * @return bool + */ + public function supports(): bool + { + return $this->entityClass === $this->actualEntityName; + } + + /** + * @return string + */ + public function getActualEntityName() + { + return $this->actualEntityName; + } +} diff --git a/lib/RoadizCoreBundle/src/Doctrine/Event/FilterQueryBuilderEvent.php b/lib/RoadizCoreBundle/src/Doctrine/Event/FilterQueryBuilderEvent.php new file mode 100644 index 00000000..983b74a8 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Doctrine/Event/FilterQueryBuilderEvent.php @@ -0,0 +1,61 @@ +queryBuilder = $queryBuilder; + $this->entityClass = $entityClass; + } + + /** + * @return QueryBuilder + */ + public function getQueryBuilder(): QueryBuilder + { + return $this->queryBuilder; + } + + /** + * @param QueryBuilder $queryBuilder + * @return FilterQueryBuilderEvent + */ + public function setQueryBuilder(QueryBuilder $queryBuilder) + { + $this->queryBuilder = $queryBuilder; + return $this; + } + + + /** + * @param string $entityClass + * @return bool + */ + public function supports($entityClass): bool + { + return $this->entityClass === $entityClass; + } +} diff --git a/lib/RoadizCoreBundle/src/Doctrine/Event/FilterQueryCriteriaEvent.php b/lib/RoadizCoreBundle/src/Doctrine/Event/FilterQueryCriteriaEvent.php new file mode 100644 index 00000000..baa4f5c5 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Doctrine/Event/FilterQueryCriteriaEvent.php @@ -0,0 +1,88 @@ +entityClass = $entityClass; + $this->property = $property; + $this->value = $value; + $this->query = $query; + } + + /** + * @return Query + */ + public function getQuery(): Query + { + return $this->query; + } + + /** + * @param Query $query + * + * @return FilterQueryCriteriaEvent + */ + public function setQuery(Query $query) + { + $this->query = $query; + + return $this; + } + + /** + * @return string + */ + public function getProperty() + { + return $this->property; + } + + /** + * @return mixed + */ + public function getValue() + { + return $this->value; + } + + + /** + * @param string $entityClass + * @return bool + */ + public function supports($entityClass): bool + { + return $this->entityClass === $entityClass; + } +} diff --git a/lib/RoadizCoreBundle/src/Doctrine/Event/QueryBuilder/QueryBuilderApplyEvent.php b/lib/RoadizCoreBundle/src/Doctrine/Event/QueryBuilder/QueryBuilderApplyEvent.php new file mode 100644 index 00000000..739b7692 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Doctrine/Event/QueryBuilder/QueryBuilderApplyEvent.php @@ -0,0 +1,11 @@ +query = $query; + $this->entityClass = $entityClass; + } + + /** + * @return Query + */ + public function getQuery(): Query + { + return $this->query; + } + + /** + * @return string + */ + public function getEntityClass(): string + { + return $this->entityClass; + } +} diff --git a/lib/RoadizCoreBundle/src/Doctrine/Event/QueryNodesSourcesEvent.php b/lib/RoadizCoreBundle/src/Doctrine/Event/QueryNodesSourcesEvent.php new file mode 100644 index 00000000..f4dc8a38 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Doctrine/Event/QueryNodesSourcesEvent.php @@ -0,0 +1,52 @@ +actualEntityName = $actualEntityName; + } + + /** + * @return string + */ + public function getActualEntityName(): string + { + return $this->actualEntityName; + } + + /** + * @return bool + * @throws \ReflectionException + */ + public function supports(): bool + { + if ($this->actualEntityName === NodesSources::class) { + return true; + } + + $reflectionClass = new \ReflectionClass($this->actualEntityName); + if ($reflectionClass->isSubclassOf(NodesSources::class)) { + return true; + } + + return false; + } +} diff --git a/lib/RoadizCoreBundle/src/Doctrine/EventSubscriber/AttributeValueLifeCycleSubscriber.php b/lib/RoadizCoreBundle/src/Doctrine/EventSubscriber/AttributeValueLifeCycleSubscriber.php new file mode 100644 index 00000000..9e0b1e5e --- /dev/null +++ b/lib/RoadizCoreBundle/src/Doctrine/EventSubscriber/AttributeValueLifeCycleSubscriber.php @@ -0,0 +1,93 @@ +getObject(); + if ($entity instanceof AttributeValueInterface) { + /* + * Automatically set position only if not manually set before. + */ + if ($entity->getPosition() === 0.0) { + /* + * Get the last index after last node in parent + */ + $nodeAttributes = $entity->getAttributable()->getAttributeValues(); + $lastPosition = 1; + foreach ($nodeAttributes as $nodeAttribute) { + $nodeAttribute->setPosition($lastPosition); + $lastPosition++; + } + + $entity->setPosition($lastPosition); + } + } + } + + /** + * @param OnFlushEventArgs $eventArgs + * + * @throws \Exception + */ + public function onFlush(OnFlushEventArgs $eventArgs): void + { + $em = $eventArgs->getObjectManager(); + $uow = $em->getUnitOfWork(); + + foreach ($uow->getScheduledEntityUpdates() as $entity) { + if ($entity instanceof AttributeValueInterface) { + $classMetadata = $em->getClassMetadata(AttributeValue::class); + foreach ($uow->getEntityChangeSet($entity) as $keyField => $field) { + if ($keyField === 'position') { + $nodeAttributes = $entity->getAttributable()->getAttributeValues(); + /* + * Need to resort collection based on updated position. + */ + $iterator = $nodeAttributes->getIterator(); + if ($iterator instanceof ArrayIterator) { + // define ordering closure, using preferred comparison method/field + $iterator->uasort(function (AttributeValueInterface $first, AttributeValueInterface $second) { + return $first->getPosition() > $second->getPosition() ? 1 : -1; + }); + } + + $lastPosition = 1; + /** @var AttributeValueInterface $nodeAttribute */ + foreach ($iterator as $nodeAttribute) { + $nodeAttribute->setPosition($lastPosition); + $uow->computeChangeSet($classMetadata, $nodeAttribute); + $lastPosition++; + } + } + } + } + } + } +} diff --git a/lib/RoadizCoreBundle/src/Doctrine/EventSubscriber/CustomFormFieldLifeCycleSubscriber.php b/lib/RoadizCoreBundle/src/Doctrine/EventSubscriber/CustomFormFieldLifeCycleSubscriber.php new file mode 100644 index 00000000..bb8122e1 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Doctrine/EventSubscriber/CustomFormFieldLifeCycleSubscriber.php @@ -0,0 +1,63 @@ +customFormFieldHandler = $customFormFieldHandler; + } + + /** + * {@inheritdoc} + */ + public function getSubscribedEvents(): array + { + return [ + Events::prePersist, + ]; + } + + /** + * @param LifecycleEventArgs $event + */ + public function prePersist(LifecycleEventArgs $event): void + { + $field = $event->getObject(); + if ($field instanceof CustomFormField) { + /* + * Automatically set position only if not manually set before. + */ + if ($field->getPosition() === 0.0) { + /* + * Get the last index after last node in parent + */ + $this->customFormFieldHandler->setCustomFormField($field); + $lastPosition = $this->customFormFieldHandler->cleanPositions(false); + if ($lastPosition > 1) { + /* + * Need to decrement position because current field is already + * in custom-form field collection count. + */ + $field->setPosition($lastPosition - 1); + } else { + $field->setPosition($lastPosition); + } + } + } + } +} diff --git a/lib/RoadizCoreBundle/src/Doctrine/EventSubscriber/NodesSourcesInheritanceSubscriber.php b/lib/RoadizCoreBundle/src/Doctrine/EventSubscriber/NodesSourcesInheritanceSubscriber.php new file mode 100644 index 00000000..7f4bde9b --- /dev/null +++ b/lib/RoadizCoreBundle/src/Doctrine/EventSubscriber/NodesSourcesInheritanceSubscriber.php @@ -0,0 +1,126 @@ +nodeTypes = $nodeTypes; + $this->inheritanceType = $inheritanceType; + } + + /** + * @inheritDoc + */ + public function getSubscribedEvents(): array + { + return [ + Events::loadClassMetadata, + Events::postLoad + ]; + } + + public function postLoad(LifecycleEventArgs $event): void + { + $object = $event->getObject(); + if ($object instanceof NodesSources) { + $object->injectObjectManager($event->getObjectManager()); + } + } + + /** + * @param LoadClassMetadataEventArgs $eventArgs + */ + public function loadClassMetadata(LoadClassMetadataEventArgs $eventArgs): void + { + // the $metadata is all the mapping info for this class + $metadata = $eventArgs->getClassMetadata(); + // the annotation reader accepts a ReflectionClass, which can be + // obtained from the $metadata + $class = $metadata->getReflectionClass(); + + if ($class->getName() === NodesSources::class) { + try { + /** @var NodeType[] $nodeTypes */ + $nodeTypes = $this->nodeTypes->all(); + $map = []; + foreach ($nodeTypes as $type) { + $map[strtolower($type->getName())] = $type->getSourceEntityFullQualifiedClassName(); + } + $metadata->setDiscriminatorMap($map); + + /* + * MAKE SURE these parameters are synced with NodesSources.php annotations. + */ + $nodeSourceTableAnnotation = [ + 'name' => $metadata->getTableName(), + 'indexes' => [ + ['columns' => ['discr']], + ['columns' => ['title']], + ['columns' => ['published_at']], + 'ns_node_translation_published' => ['columns' => ['node_id', 'translation_id', 'published_at']], + 'ns_node_discr_translation' => ['columns' => ['node_id', 'discr', 'translation_id']], + 'ns_node_discr_translation_published' => ['columns' => ['node_id', 'discr', 'translation_id', 'published_at']], + 'ns_translation_published' => ['columns' => ['translation_id', 'published_at']], + 'ns_discr_translation' => ['columns' => ['discr', 'translation_id']], + 'ns_discr_translation_published' => ['columns' => ['discr', 'translation_id', 'published_at']], + 'ns_title_published' => ['columns' => ['title', 'published_at']], + 'ns_title_translation_published' => ['columns' => ['title', 'translation_id', 'published_at']], + ], + 'uniqueConstraints' => [ + ['columns' => ["node_id", "translation_id"]] + ] + ]; + + if ($this->inheritanceType === Configuration::INHERITANCE_TYPE_JOINED) { + $metadata->setInheritanceType(ClassMetadataInfo::INHERITANCE_TYPE_JOINED); + } elseif ($this->inheritanceType === Configuration::INHERITANCE_TYPE_SINGLE_TABLE) { + $metadata->setInheritanceType(ClassMetadataInfo::INHERITANCE_TYPE_SINGLE_TABLE); + /* + * If inheritance type is single table, we need to set indexes on parent class: NodesSources + */ + foreach ($nodeTypes as $type) { + $indexedFields = $type->getFields()->filter(function (NodeTypeFieldInterface $field) { + return $field->isIndexed(); + }); + /** @var NodeTypeFieldInterface $indexedField */ + foreach ($indexedFields as $indexedField) { + $nodeSourceTableAnnotation['indexes']['nsapp_' . $indexedField->getName()] = [ + 'columns' => [$indexedField->getName()], + ]; + } + } + } else { + throw new \RuntimeException('Inheritance type not supported: ' . $this->inheritanceType); + } + $metadata->setPrimaryTable($nodeSourceTableAnnotation); + } catch (\Exception $e) { + /* + * Database tables don't exist yet + * Need Install + */ + } + } + } +} diff --git a/lib/RoadizCoreBundle/src/Doctrine/EventSubscriber/SettingLifeCycleSubscriber.php b/lib/RoadizCoreBundle/src/Doctrine/EventSubscriber/SettingLifeCycleSubscriber.php new file mode 100644 index 00000000..96e5a485 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Doctrine/EventSubscriber/SettingLifeCycleSubscriber.php @@ -0,0 +1,123 @@ +uniqueKeyEncoderFactory = $uniqueKeyEncoderFactory; + $this->privateKeyName = $privateKeyName; + $this->logger = $logger; + } + + /** + * {@inheritdoc} + */ + public function getSubscribedEvents(): array + { + return [ + Events::preUpdate, + Events::postLoad + ]; + } + + /** + * @param PreUpdateEventArgs $event + * @throws InvalidKey + */ + public function preUpdate(PreUpdateEventArgs $event): void + { + $setting = $event->getObject(); + if ($setting instanceof Setting) { + if ( + $event->hasChangedField('encrypted') && + $event->getNewValue('encrypted') === false && + null !== $setting->getRawValue() + ) { + /* + * Set raw value and do not encode it if setting is not encrypted anymore. + */ + $setting->setValue($setting->getRawValue()); + } elseif ( + $event->hasChangedField('encrypted') && + $event->getNewValue('encrypted') === true && + null !== $setting->getRawValue() + ) { + /* + * Encode value for the first time. + */ + $setting->setValue($this->getEncoder()->encode(new HiddenString($setting->getRawValue()))); + } elseif ( + $setting->isEncrypted() && + $event->hasChangedField('value') && + null !== $event->getNewValue('value') + ) { + /* + * Encode setting if value has changed + */ + $event->setNewValue('value', $this->getEncoder()->encode(new HiddenString($event->getNewValue('value')))); + $setting->setClearValue($event->getNewValue('value')); + } + } + } + + /** + * @param LifecycleEventArgs $event + */ + public function postLoad(LifecycleEventArgs $event): void + { + $setting = $event->getObject(); + if ( + $setting instanceof Setting && + $setting->isEncrypted() && + null !== $setting->getRawValue() + ) { + try { + $setting->setClearValue($this->getEncoder()->decode($setting->getRawValue())->getString()); + } catch (InvalidKey $exception) { + $this->logger->error( + sprintf('Failed to decode "%s" setting value', $setting->getName()), + [ + 'exception_message' => $exception->getMessage(), + 'trace' => $exception->getTraceAsString(), + ] + ); + } catch (InvalidMessage $exception) { + $this->logger->error( + sprintf('Failed to decode "%s" setting value', $setting->getName()), + [ + 'exception_message' => $exception->getMessage(), + 'trace' => $exception->getTraceAsString(), + ] + ); + } + } + } + + /** + * @throws InvalidKey + */ + protected function getEncoder(): UniqueKeyEncoderInterface + { + return $this->uniqueKeyEncoderFactory->getEncoder($this->privateKeyName); + } +} diff --git a/lib/RoadizCoreBundle/src/Doctrine/EventSubscriber/TablePrefixSubscriber.php b/lib/RoadizCoreBundle/src/Doctrine/EventSubscriber/TablePrefixSubscriber.php new file mode 100644 index 00000000..e01c4c69 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Doctrine/EventSubscriber/TablePrefixSubscriber.php @@ -0,0 +1,61 @@ +tablesPrefix = $tablesPrefix; + } + + + /** + * @inheritDoc + */ + public function getSubscribedEvents(): array + { + return [ + Events::loadClassMetadata, + ]; + } + + /** + * @param LoadClassMetadataEventArgs $eventArgs + */ + public function loadClassMetadata(LoadClassMetadataEventArgs $eventArgs): void + { + /* + * Prefix tables + */ + if (!empty($this->tablesPrefix) && $this->tablesPrefix !== '') { + // the $metadata is all the mapping info for this class + $metadata = $eventArgs->getClassMetadata(); + $metadata->table['name'] = $this->tablesPrefix . '_' . $metadata->table['name']; + + /* + * Prefix join tables + */ + foreach ($metadata->associationMappings as $key => $association) { + if (!empty($association['joinTable']['name'])) { + $metadata->associationMappings[$key]['joinTable']['name'] = + $this->tablesPrefix . '_' . $association['joinTable']['name']; + } + } + } + } +} diff --git a/lib/RoadizCoreBundle/src/Doctrine/EventSubscriber/UserLifeCycleSubscriber.php b/lib/RoadizCoreBundle/src/Doctrine/EventSubscriber/UserLifeCycleSubscriber.php new file mode 100644 index 00000000..f6183e1b --- /dev/null +++ b/lib/RoadizCoreBundle/src/Doctrine/EventSubscriber/UserLifeCycleSubscriber.php @@ -0,0 +1,210 @@ +userViewer = $userViewer; + $this->dispatcher = $dispatcher; + $this->logger = $logger; + $this->passwordHasherFactory = $passwordHasherFactory; + } + + /** + * {@inheritdoc} + */ + public function getSubscribedEvents(): array + { + return [ + Events::preUpdate, + Events::prePersist, + Events::postPersist, + Events::postUpdate, + Events::postRemove, + ]; + } + + /** + * @param PreUpdateEventArgs $event + * @return void + * @throws \GuzzleHttp\Exception\GuzzleException + */ + public function preUpdate(PreUpdateEventArgs $event): void + { + $user = $event->getEntity(); + if ($user instanceof User) { + if ( + $event->hasChangedField('enabled') && + true === $event->getNewValue('enabled') + ) { + $userEvent = new UserEnabledEvent($user); + $this->dispatcher->dispatch($userEvent); + } + + if ( + $event->hasChangedField('enabled') && + false === $event->getNewValue('enabled') + ) { + $userEvent = new UserDisabledEvent($user); + $this->dispatcher->dispatch($userEvent); + } + + if ($event->hasChangedField('facebookName')) { + if ('' != $event->getNewValue('facebookName')) { + try { + $facebook = new FacebookPictureFinder($user->getFacebookName()); + $url = $facebook->getPictureUrl(); + $user->setPictureUrl($url); + } catch (\Exception $e) { + $user->setFacebookName(''); + $user->setPictureUrl($user->getGravatarUrl()); + } + } else { + $user->setPictureUrl($user->getGravatarUrl()); + } + } + /* + * Encode user password + */ + if ( + $event->hasChangedField('password') && + null !== $user->getPlainPassword() && + '' !== $user->getPlainPassword() + ) { + $this->setPassword($user, $user->getPlainPassword()); + $userEvent = new UserPasswordChangedEvent($user); + $this->dispatcher->dispatch($userEvent); + } + } + } + + /** + * @param User $user + * @param string|null $plainPassword + */ + protected function setPassword(User $user, ?string $plainPassword): void + { + if (null !== $plainPassword) { + $hasher = $this->passwordHasherFactory->getPasswordHasher($user); + $encodedPassword = $hasher->hash($plainPassword); + $user->setPassword($encodedPassword); + } + } + + /** + * @param LifecycleEventArgs $event + */ + public function postUpdate(LifecycleEventArgs $event): void + { + $user = $event->getObject(); + if ($user instanceof User) { + $userEvent = new UserUpdatedEvent($user); + $this->dispatcher->dispatch($userEvent); + } + } + + /** + * @param LifecycleEventArgs $event + */ + public function postRemove(LifecycleEventArgs $event): void + { + $user = $event->getObject(); + if ($user instanceof User) { + $userEvent = new UserDeletedEvent($user); + $this->dispatcher->dispatch($userEvent); + } + } + + /** + * @param LifecycleEventArgs $event + * + * @throws \Exception + */ + public function postPersist(LifecycleEventArgs $event): void + { + $user = $event->getObject(); + if ($user instanceof User) { + $userEvent = new UserCreatedEvent($user); + $this->dispatcher->dispatch($userEvent); + } + } + + /** + * @param LifecycleEventArgs $event + * + * @throws \Exception + */ + public function prePersist(LifecycleEventArgs $event): void + { + $user = $event->getObject(); + if ($user instanceof User) { + if ( + $user->willSendCreationConfirmationEmail() && + (null === $user->getPlainPassword() || + $user->getPlainPassword() === '') + ) { + /* + * Do not generate password for new users + * just send them a password reset link. + */ + $tokenGenerator = new TokenGenerator($this->logger); + $user->setCredentialsExpired(true); + $user->setPasswordRequestedAt(new \DateTime()); + $user->setConfirmationToken($tokenGenerator->generateToken()); + + $this->userViewer->setUser($user); + $this->userViewer->sendPasswordResetLink( + 'loginResetPage', + '@RoadizCore/email/users/welcome_user_email.html.twig', + '@RoadizCore/email/users/welcome_user_email.txt.twig' + ); + } else { + $this->setPassword($user, $user->getPlainPassword()); + } + + /* + * Force a Gravatar image if not defined + */ + if (empty($user->getPictureUrl())) { + $user->setPictureUrl($user->getGravatarUrl()); + } + } + } +} diff --git a/lib/RoadizCoreBundle/src/Doctrine/Loggable/UserLoggableListener.php b/lib/RoadizCoreBundle/src/Doctrine/Loggable/UserLoggableListener.php new file mode 100644 index 00000000..358df186 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Doctrine/Loggable/UserLoggableListener.php @@ -0,0 +1,57 @@ +user; + } + + /** + * @param UserInterface|null $user + * + * @return UserLoggableListener + */ + public function setUser(?UserInterface $user): UserLoggableListener + { + $this->user = $user; + if (null !== $user) { + $this->setUsername($user->getUsername()); + } + + return $this; + } + + /** + * Handle any custom LogEntry functionality that needs to be performed + * before persisting it + * + * @param object $logEntry The LogEntry being persisted + * @param object $object The object being Logged + * + * @return void + */ + protected function prePersistLogEntry($logEntry, $object): void + { + parent::prePersistLogEntry($logEntry, $object); + + $user = $this->getUser(); + if ($logEntry instanceof UserLogEntry && $user instanceof User) { + $logEntry->setUser($user); + } + } +} diff --git a/lib/RoadizCoreBundle/src/Doctrine/ORM/Filter/ANodesFilter.php b/lib/RoadizCoreBundle/src/Doctrine/ORM/Filter/ANodesFilter.php new file mode 100644 index 00000000..8548be74 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Doctrine/ORM/Filter/ANodesFilter.php @@ -0,0 +1,158 @@ + [['onNodesSourcesQueryBuilderBuild', 40]], + QueryBuilderBuildEvent::class => [['onNodeQueryBuilderBuild', 30]] + ]; + } + + /** + * @return string + */ + protected function getProperty(): string + { + return 'aNodes'; + } + + /** + * @return string + */ + protected function getNodeJoinAlias(): string + { + return 'a_n'; + } + + /** + * @return string + */ + protected function getNodeFieldJoinAlias(): string + { + return 'a_n_f'; + } + + /** + * @param QueryBuilderBuildEvent $event + */ + public function onNodeQueryBuilderBuild(QueryBuilderBuildEvent $event): void + { + if ($event->supports() && $event->getActualEntityName() === Node::class) { + $simpleQB = new SimpleQueryBuilder($event->getQueryBuilder()); + if (str_contains($event->getProperty(), $this->getProperty() . '.')) { + // Prevent other query builder filters to execute + $event->stopPropagation(); + $qb = $event->getQueryBuilder(); + $baseKey = $simpleQB->getParameterKey($event->getProperty()); + + if ( + !$simpleQB->joinExists( + $simpleQB->getRootAlias(), + $this->getNodeJoinAlias() + ) + ) { + $qb->innerJoin( + $simpleQB->getRootAlias() . '.' . $this->getProperty(), + $this->getNodeJoinAlias() + ); + } + if (str_contains($event->getProperty(), $this->getProperty() . '.field.')) { + if ( + !$simpleQB->joinExists( + $simpleQB->getRootAlias(), + $this->getNodeFieldJoinAlias() + ) + ) { + $qb->innerJoin( + $this->getNodeJoinAlias() . '.field', + $this->getNodeFieldJoinAlias() + ); + } + $prefix = $this->getNodeFieldJoinAlias() . '.'; + $key = str_replace($this->getProperty() . '.field.', '', $event->getProperty()); + } else { + $prefix = $this->getNodeJoinAlias() . '.'; + $key = str_replace($this->getProperty() . '.', '', $event->getProperty()); + } + + $qb->andWhere($simpleQB->buildExpressionWithoutBinding($event->getValue(), $prefix, $key, $baseKey)); + } + } + } + + /** + * @param QueryBuilderNodesSourcesBuildEvent $event + */ + public function onNodesSourcesQueryBuilderBuild(QueryBuilderNodesSourcesBuildEvent $event): void + { + if ($event->supports()) { + $simpleQB = new SimpleQueryBuilder($event->getQueryBuilder()); + if (str_contains($event->getProperty(), 'node.' . $this->getProperty() . '.')) { + // Prevent other query builder filters to execute + $event->stopPropagation(); + $qb = $event->getQueryBuilder(); + $baseKey = $simpleQB->getParameterKey($event->getProperty()); + + if ( + !$simpleQB->joinExists( + $simpleQB->getRootAlias(), + EntityRepository::NODE_ALIAS + ) + ) { + $qb->innerJoin( + $simpleQB->getRootAlias() . '.node', + EntityRepository::NODE_ALIAS + ); + } + + if ( + !$simpleQB->joinExists( + $simpleQB->getRootAlias(), + $this->getNodeJoinAlias() + ) + ) { + $qb->innerJoin( + EntityRepository::NODE_ALIAS . '.' . $this->getProperty(), + $this->getNodeJoinAlias() + ); + } + if (str_contains($event->getProperty(), 'node.' . $this->getProperty() . '.field.')) { + if ( + !$simpleQB->joinExists( + $simpleQB->getRootAlias(), + $this->getNodeFieldJoinAlias() + ) + ) { + $qb->innerJoin( + $this->getNodeJoinAlias() . '.field', + $this->getNodeFieldJoinAlias() + ); + } + $prefix = $this->getNodeFieldJoinAlias() . '.'; + $key = str_replace('node.' . $this->getProperty() . '.field.', '', $event->getProperty()); + } else { + $prefix = $this->getNodeJoinAlias() . '.'; + $key = str_replace('node.' . $this->getProperty() . '.', '', $event->getProperty()); + } + + $qb->andWhere($simpleQB->buildExpressionWithoutBinding($event->getValue(), $prefix, $key, $baseKey)); + } + } + } +} diff --git a/lib/RoadizCoreBundle/src/Doctrine/ORM/Filter/BNodesFilter.php b/lib/RoadizCoreBundle/src/Doctrine/ORM/Filter/BNodesFilter.php new file mode 100644 index 00000000..032b9e7a --- /dev/null +++ b/lib/RoadizCoreBundle/src/Doctrine/ORM/Filter/BNodesFilter.php @@ -0,0 +1,46 @@ + [['onNodesSourcesQueryBuilderBuild', 40]], + QueryBuilderBuildEvent::class => [['onNodeQueryBuilderBuild', 30]] + ]; + } + + /** + * @return string + */ + protected function getProperty(): string + { + return 'bNodes'; + } + + /** + * @return string + */ + protected function getNodeJoinAlias(): string + { + return 'b_n'; + } + + /** + * @return string + */ + protected function getNodeFieldJoinAlias(): string + { + return 'b_n_f'; + } +} diff --git a/lib/RoadizCoreBundle/src/Doctrine/ORM/Filter/NodeTranslationFilter.php b/lib/RoadizCoreBundle/src/Doctrine/ORM/Filter/NodeTranslationFilter.php new file mode 100644 index 00000000..5f86e660 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Doctrine/ORM/Filter/NodeTranslationFilter.php @@ -0,0 +1,118 @@ + [ + // This event must be the last to perform + ['onTranslationPrefixFilter', 0], + ['onTranslationFilter', -10], + ] + ]; + } + + /** + * @param QueryBuilderBuildEvent $event + * + * @return bool + */ + protected function supports(QueryBuilderBuildEvent $event): bool + { + return $event->supports() && + $event->getActualEntityName() === Node::class && + str_contains($event->getProperty(), 'translation'); + } + + /** + * @param QueryBuilderBuildEvent $event + */ + public function onTranslationPrefixFilter(QueryBuilderBuildEvent $event): void + { + if ($this->supports($event)) { + $simpleQB = new SimpleQueryBuilder($event->getQueryBuilder()); + if (str_contains($event->getProperty(), 'translation.')) { + // Prevent other query builder filters to execute + $event->stopPropagation(); + $qb = $event->getQueryBuilder(); + $baseKey = $simpleQB->getParameterKey($event->getProperty()); + + if ( + !$simpleQB->joinExists( + $simpleQB->getRootAlias(), + EntityRepository::NODESSOURCES_ALIAS + ) + ) { + $qb->innerJoin( + $simpleQB->getRootAlias() . '.nodeSources', + EntityRepository::NODESSOURCES_ALIAS + ); + } + + if ( + !$simpleQB->joinExists( + $simpleQB->getRootAlias(), + EntityRepository::TRANSLATION_ALIAS + ) + ) { + $qb->innerJoin( + EntityRepository::NODESSOURCES_ALIAS . '.translation', + EntityRepository::TRANSLATION_ALIAS + ); + } + + $prefix = EntityRepository::TRANSLATION_ALIAS . '.'; + $key = str_replace('translation.', '', $event->getProperty()); + $qb->andWhere($simpleQB->buildExpressionWithoutBinding($event->getValue(), $prefix, $key, $baseKey)); + } + } + } + + /** + * @param QueryBuilderBuildEvent $event + */ + public function onTranslationFilter(QueryBuilderBuildEvent $event): void + { + if ($this->supports($event)) { + $simpleQB = new SimpleQueryBuilder($event->getQueryBuilder()); + if ($event->getProperty() === 'translation') { + // Prevent other query builder filters to execute + $event->stopPropagation(); + $qb = $event->getQueryBuilder(); + $baseKey = $simpleQB->getParameterKey($event->getProperty()); + + if ( + !$simpleQB->joinExists( + $simpleQB->getRootAlias(), + EntityRepository::NODESSOURCES_ALIAS + ) + ) { + $qb->innerJoin( + $simpleQB->getRootAlias() . '.nodeSources', + EntityRepository::NODESSOURCES_ALIAS + ); + } + + $prefix = EntityRepository::NODESSOURCES_ALIAS . '.'; + $key = $event->getProperty(); + $qb->andWhere($simpleQB->buildExpressionWithoutBinding($event->getValue(), $prefix, $key, $baseKey)); + } + } + } +} diff --git a/lib/RoadizCoreBundle/src/Doctrine/ORM/Filter/NodeTypeFilter.php b/lib/RoadizCoreBundle/src/Doctrine/ORM/Filter/NodeTypeFilter.php new file mode 100644 index 00000000..561797e7 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Doctrine/ORM/Filter/NodeTypeFilter.php @@ -0,0 +1,109 @@ + [['onNodesSourcesQueryBuilderBuild', 40]], + QueryBuilderBuildEvent::class => [ + ['onNodeQueryBuilderBuild', 30], + ] + ]; + } + + protected function supports(QueryBuilderBuildEvent $event): bool + { + return $event->supports() && str_contains($event->getProperty(), 'nodeType.'); + } + + /** + * @param QueryBuilderBuildEvent $event + */ + public function onNodeQueryBuilderBuild(QueryBuilderBuildEvent $event): void + { + if ($this->supports($event)) { + // Prevent other query builder filters to execute + $event->stopPropagation(); + $simpleQB = new SimpleQueryBuilder($event->getQueryBuilder()); + $qb = $event->getQueryBuilder(); + $baseKey = $simpleQB->getParameterKey($event->getProperty()); + + if ( + !$simpleQB->joinExists( + $simpleQB->getRootAlias(), + EntityRepository::NODETYPE_ALIAS + ) + ) { + $qb->addSelect(EntityRepository::NODETYPE_ALIAS); + $qb->innerJoin( + $simpleQB->getRootAlias() . '.nodeType', + EntityRepository::NODETYPE_ALIAS + ); + } + + $prefix = EntityRepository::NODETYPE_ALIAS . '.'; + $key = str_replace('nodeType.', '', $event->getProperty()); + $qb->andWhere($simpleQB->buildExpressionWithoutBinding($event->getValue(), $prefix, $key, $baseKey)); + } + } + + /** + * @param QueryBuilderNodesSourcesBuildEvent $event + */ + public function onNodesSourcesQueryBuilderBuild(QueryBuilderNodesSourcesBuildEvent $event): void + { + if ($this->supports($event)) { + $simpleQB = new SimpleQueryBuilder($event->getQueryBuilder()); + if (str_contains($event->getProperty(), 'node.nodeType.')) { + // Prevent other query builder filters to execute + $event->stopPropagation(); + $qb = $event->getQueryBuilder(); + $baseKey = $simpleQB->getParameterKey($event->getProperty()); + + if ( + !$simpleQB->joinExists( + $simpleQB->getRootAlias(), + EntityRepository::NODE_ALIAS + ) + ) { + $qb->innerJoin( + $simpleQB->getRootAlias() . '.node', + EntityRepository::NODE_ALIAS + ); + } + if ( + !$simpleQB->joinExists( + $simpleQB->getRootAlias(), + EntityRepository::NODETYPE_ALIAS + ) + ) { + $qb->addSelect(EntityRepository::NODETYPE_ALIAS); + $qb->innerJoin( + EntityRepository::NODE_ALIAS . '.nodeType', + EntityRepository::NODETYPE_ALIAS + ); + } + + $prefix = EntityRepository::NODETYPE_ALIAS . '.'; + $key = str_replace('node.nodeType.', '', $event->getProperty()); + $qb->andWhere($simpleQB->buildExpressionWithoutBinding($event->getValue(), $prefix, $key, $baseKey)); + } + } + } +} diff --git a/lib/RoadizCoreBundle/src/Doctrine/ORM/Filter/NodesSourcesNodeFilter.php b/lib/RoadizCoreBundle/src/Doctrine/ORM/Filter/NodesSourcesNodeFilter.php new file mode 100644 index 00000000..3a4f3310 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Doctrine/ORM/Filter/NodesSourcesNodeFilter.php @@ -0,0 +1,65 @@ + [['onNodesSourcesQueryBuilderBuild', -10]], + ]; + } + + /** + * @param QueryBuilderNodesSourcesBuildEvent $event + * + * @return bool + */ + protected function supports(QueryBuilderNodesSourcesBuildEvent $event): bool + { + return $event->supports() && str_contains($event->getProperty(), 'node.'); + } + + /** + * @param QueryBuilderNodesSourcesBuildEvent $event + */ + public function onNodesSourcesQueryBuilderBuild(QueryBuilderNodesSourcesBuildEvent $event): void + { + if ($this->supports($event)) { + // Prevent other query builder filters to execute + $event->stopPropagation(); + $simpleQB = new SimpleQueryBuilder($event->getQueryBuilder()); + $qb = $event->getQueryBuilder(); + $baseKey = $simpleQB->getParameterKey($event->getProperty()); + + if ( + !$simpleQB->joinExists( + $simpleQB->getRootAlias(), + EntityRepository::NODE_ALIAS + ) + ) { + $qb->innerJoin( + $simpleQB->getRootAlias() . '.node', + EntityRepository::NODE_ALIAS + ); + } + + $prefix = EntityRepository::NODE_ALIAS . '.'; + $key = str_replace('node.', '', $event->getProperty()); + $qb->andWhere($simpleQB->buildExpressionWithoutBinding($event->getValue(), $prefix, $key, $baseKey)); + } + } +} diff --git a/lib/RoadizCoreBundle/src/Doctrine/ORM/Filter/NodesSourcesNodeTypeFilter.php b/lib/RoadizCoreBundle/src/Doctrine/ORM/Filter/NodesSourcesNodeTypeFilter.php new file mode 100644 index 00000000..62d943e5 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Doctrine/ORM/Filter/NodesSourcesNodeTypeFilter.php @@ -0,0 +1,92 @@ + [['onNodesSourcesQueryBuilderBuild', -9]], + QueryBuilderNodesSourcesApplyEvent::class => [['onNodesSourcesQueryBuilderApply', -9]], + ]; + } + + /** + * @param FilterNodesSourcesQueryBuilderCriteriaEvent $event + * + * @return bool + */ + protected function supports(FilterNodesSourcesQueryBuilderCriteriaEvent $event): bool + { + return $event->supports() && + $event->getProperty() === 'node.nodeType' && + ( + $event->getValue() instanceof NodeType || + (is_array($event->getValue()) && + count($event->getValue()) > 0 && + $event->getValue()[0] instanceof NodeType) + ); + } + + /** + * @param QueryBuilderNodesSourcesBuildEvent $event + */ + public function onNodesSourcesQueryBuilderBuild(QueryBuilderNodesSourcesBuildEvent $event): void + { + if ($this->supports($event)) { + // Prevent other query builder filters to execute + $event->stopPropagation(); + $qb = $event->getQueryBuilder(); + $simpleQB = new SimpleQueryBuilder($event->getQueryBuilder()); + $value = $event->getValue(); + + if ($value instanceof NodeType) { + $qb->andWhere($qb->expr()->isInstanceOf( + $simpleQB->getRootAlias(), + $value->getSourceEntityFullQualifiedClassName() + )); + } elseif (is_array($value)) { + $nodeTypes = []; + foreach ($value as $nodeType) { + if ($nodeType instanceof NodeType) { + $nodeTypes[] = $nodeType; + } + } + $nodeTypes = array_unique($nodeTypes); + + if (count($nodeTypes) > 0) { + $orX = $qb->expr()->orX(); + /** @var NodeType $nodeType */ + foreach ($nodeTypes as $nodeType) { + $orX->add($qb->expr()->isInstanceOf( + $simpleQB->getRootAlias(), + $nodeType->getSourceEntityFullQualifiedClassName() + )); + } + $qb->andWhere($orX); + } + } + } + } + + public function onNodesSourcesQueryBuilderApply(QueryBuilderNodesSourcesApplyEvent $event): void + { + if ($this->supports($event)) { + // Prevent other query builder filters to execute + $event->stopPropagation(); + } + } +} diff --git a/lib/RoadizCoreBundle/src/Doctrine/ORM/Filter/NodesSourcesReachableFilter.php b/lib/RoadizCoreBundle/src/Doctrine/ORM/Filter/NodesSourcesReachableFilter.php new file mode 100644 index 00000000..03528da7 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Doctrine/ORM/Filter/NodesSourcesReachableFilter.php @@ -0,0 +1,99 @@ +nodeTypesBag = $nodeTypesBag; + } + + public static function getSubscribedEvents(): array + { + return [ + QueryBuilderNodesSourcesBuildEvent::class => [['onNodesSourcesQueryBuilderBuild', 41]], + QueryBuilderNodesSourcesApplyEvent::class => [['onNodesSourcesQueryBuilderApply', 41]], + QueryNodesSourcesEvent::class => [['onQueryNodesSourcesEvent', 0]], + ]; + } + + /** + * @param FilterNodesSourcesQueryBuilderCriteriaEvent $event + * + * @return bool + */ + protected function supports(FilterNodesSourcesQueryBuilderCriteriaEvent $event): bool + { + return $event->supports() && + in_array($event->getProperty(), self::PARAMETER) && + is_bool($event->getValue()); + } + + /** + * @param QueryBuilderNodesSourcesBuildEvent $event + */ + public function onNodesSourcesQueryBuilderBuild(QueryBuilderNodesSourcesBuildEvent $event): void + { + if ($this->supports($event)) { + // Prevent other query builder filters to execute + $event->stopPropagation(); + $qb = $event->getQueryBuilder(); + $simpleQB = new SimpleQueryBuilder($event->getQueryBuilder()); + $value = (bool) $event->getValue(); + + $nodeTypes = array_unique(array_filter($this->nodeTypesBag->all(), function (NodeType $nodeType) use ($value) { + return $nodeType->getReachable() === $value; + })); + + if (count($nodeTypes) > 0) { + $orX = $qb->expr()->orX(); + /** @var NodeType $nodeType */ + foreach ($nodeTypes as $nodeType) { + $orX->add($qb->expr()->isInstanceOf( + $simpleQB->getRootAlias(), + $nodeType->getSourceEntityFullQualifiedClassName() + )); + } + $qb->andWhere($orX); + } + } + } + + public function onNodesSourcesQueryBuilderApply(QueryBuilderNodesSourcesApplyEvent $event): void + { + if ($this->supports($event)) { + // Prevent other query builder filters to execute + $event->stopPropagation(); + } + } + + public function onQueryNodesSourcesEvent(QueryNodesSourcesEvent $event): void + { + // TODO: Find a way to reduce NodeSource query joins when filtered by node-types. + } +} diff --git a/lib/RoadizCoreBundle/src/Doctrine/ORM/SimpleQueryBuilder.php b/lib/RoadizCoreBundle/src/Doctrine/ORM/SimpleQueryBuilder.php new file mode 100644 index 00000000..cc1fe398 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Doctrine/ORM/SimpleQueryBuilder.php @@ -0,0 +1,237 @@ +queryBuilder = $queryBuilder; + } + + /** + * @param string $key + * + * @return string + */ + public function getParameterKey(string $key): string + { + return strtolower(str_replace('.', '_', $key)); + } + + /** + * @param mixed $value + * @param string $prefix Property prefix including DOT + * @param string $key + * + * @return QueryBuilder + */ + public function buildExpressionWithBinding($value, string $prefix, string $key): QueryBuilder + { + $this->buildExpressionWithoutBinding($value, $prefix, $key); + return $this->bindValue($key, $value); + } + + /** + * @param mixed $value + * @param string $prefix + * @param string $key + * @param string|null $baseKey + * + * @return Comparison|Func|string + */ + public function buildExpressionWithoutBinding($value, string $prefix, string $key, string $baseKey = null) + { + if (strlen($prefix) > 0 && substr($prefix, -strlen('.')) !== '.') { + $prefix .= '.'; + } + + if (null === $baseKey) { + $baseKey = $this->getParameterKey($key); + } + if (is_bool($value)) { + return $this->queryBuilder->expr()->eq($prefix . $key, ':' . $baseKey); + } + if ('NOT NULL' === $value) { + return $this->queryBuilder->expr()->isNotNull($prefix . $key); + } + if (is_array($value)) { + /* + * array + * + * ['!=', $value] + * ['<=', $value] + * ['<', $value] + * ['>=', $value] + * ['>', $value] + * ['BETWEEN', $value, $value] + * ['LIKE', $value] + * ['NOT IN', [$value]] + * [$value, $value] (IN) + */ + if (count($value) > 1) { + switch ($value[0]) { + case '!=': + # neq + return $this->queryBuilder->expr()->neq($prefix . $key, ':' . $baseKey); + case '<=': + # lte + return $this->queryBuilder->expr()->lte($prefix . $key, ':' . $baseKey); + case '<': + # lt + return $this->queryBuilder->expr()->lt($prefix . $key, ':' . $baseKey); + case '>=': + # gte + return $this->queryBuilder->expr()->gte($prefix . $key, ':' . $baseKey); + case '>': + # gt + return $this->queryBuilder->expr()->gt($prefix . $key, ':' . $baseKey); + case 'BETWEEN': + return $this->queryBuilder->expr()->between( + $prefix . $key, + ':' . $baseKey . '_1', + ':' . $baseKey . '_2' + ); + case 'LIKE': + $fullKey = sprintf('LOWER(%s)', $prefix . $key); + return $this->queryBuilder->expr()->like( + $fullKey, + $this->queryBuilder->expr()->literal(strtolower($value[1] ?? '')) + ); + case 'NOT IN': + return $this->queryBuilder->expr()->notIn($prefix . $key, ':' . $baseKey); + case JsonContains::FUNCTION_NAME: + // Json flat array/object contains a given value + return JsonContains::FUNCTION_NAME . '(' . $prefix . $key . ', :' . $baseKey . ', \'$\') = 1'; + case 'INSTANCE OF': + return $this->queryBuilder->expr()->isInstanceOf($prefix . $key, ':' . $baseKey); + } + } + return $this->queryBuilder->expr()->in($prefix . $key, ':' . $baseKey); + } + if ($value instanceof PersistableInterface) { + return $this->queryBuilder->expr()->eq($prefix . $key, ':' . $baseKey); + } + if (isset($value)) { + return $this->queryBuilder->expr()->eq($prefix . $key, ':' . $baseKey); + } + if (null === $value) { + return $this->queryBuilder->expr()->isNull($prefix . $key); + } + + throw new \InvalidArgumentException('Value is not supported for expression.'); + } + + /** + * @param string $key + * @param mixed $value + * + * @return QueryBuilder + */ + public function bindValue(string $key, $value): QueryBuilder + { + $key = $this->getParameterKey($key); + + if (is_bool($value) || $value === 0) { + return $this->queryBuilder->setParameter($key, $value); + } + if ('NOT NULL' == $value) { + // param is not needed + return $this->queryBuilder; + } + if (is_array($value)) { + if (count($value) > 1) { + switch ($value[0]) { + case '!=': + case '<=': + case '<': + case '>=': + case '>': + case 'INSTANCE OF': + case 'NOT IN': + return $this->queryBuilder->setParameter($key, $value[1]); + case 'BETWEEN': + return $this->queryBuilder->setParameter($key . '_1', $value[1]) + ->setParameter($key . '_2', $value[2]); + case JsonContains::FUNCTION_NAME: + // Need to quote Json value + return $this->queryBuilder->setParameter($key, '"' . $value[1] . '"'); + case 'LIKE': + // param is set in filterBy + return $this->queryBuilder; + } + } + return $this->queryBuilder->setParameter($key, $value); + } + if ($value instanceof PersistableInterface) { + return $this->queryBuilder->setParameter($key, $value->getId()); + } + if (isset($value)) { + return $this->queryBuilder->setParameter($key, $value); + } + if (null === $value) { + return $this->queryBuilder; + } + + throw new \InvalidArgumentException('Value is not supported for binding.'); + } + + /** + * @param string $rootAlias + * @param string $joinAlias + * + * @return bool + */ + public function joinExists(string $rootAlias, string $joinAlias): bool + { + if (isset($this->queryBuilder->getDQLPart('join')[$rootAlias])) { + foreach ($this->queryBuilder->getDQLPart('join')[$rootAlias] as $join) { + if ( + null !== $join && + $join instanceof Join && + $join->getAlias() === $joinAlias + ) { + return true; + } + } + } + + return false; + } + + /** + * @return QueryBuilder + */ + public function getQueryBuilder(): QueryBuilder + { + return $this->queryBuilder; + } + + /** + * @return string|null + */ + public function getRootAlias(): ?string + { + $fromArray = $this->getQueryBuilder()->getDQLPart('from'); + if (isset($fromArray[0]) && $fromArray[0] instanceof From) { + return $fromArray[0]->getAlias(); + } + + return null; + } +} diff --git a/lib/RoadizCoreBundle/src/Doctrine/SchemaUpdater.php b/lib/RoadizCoreBundle/src/Doctrine/SchemaUpdater.php new file mode 100644 index 00000000..c7ed0268 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Doctrine/SchemaUpdater.php @@ -0,0 +1,132 @@ +logger = $logger; + $this->opCacheClearer = $opCacheClearer; + $this->projectDir = $projectDir; + } + + public function clearMetadata(): void + { + $this->opCacheClearer->clear(); + + $process = $this->runCommand( + 'doctrine:cache:clear-metadata', + ); + $process->run(); + + if ($process->wait() === 0) { + $this->logger->info('Cleared Doctrine metadata cache.'); + } else { + throw new \RuntimeException('Cannot clear Doctrine metadata cache. ' . $process->getErrorOutput()); + } + + $process = $this->runCommand( + 'messenger:stop-workers', + ); + $process->run(); + + if ($process->wait() === 0) { + $this->logger->info('Stop any running messenger worker to force them to restart'); + } else { + throw new \RuntimeException('Cannot stop messenger workers. ' . $process->getErrorOutput()); + } + } + + public function clearAllCaches(): void + { + $this->opCacheClearer->clear(); + + $process = $this->runCommand( + 'cache:clear', + ); + $process->run(); + + if ($process->wait() === 0) { + $this->logger->info('Cleared all caches.'); + } else { + throw new \RuntimeException('Cannot clear cache. ' . $process->getErrorOutput()); + } + } + + /** + * Update database schema using doctrine migration. + * + * @throws \Exception + */ + public function updateSchema(): void + { + $this->clearMetadata(); + + $process = $this->runCommand( + 'doctrine:migrations:migrate', + ); + $process->run(); + + if ($process->wait() === 0) { + $this->logger->info('Executed pending migrations.'); + } else { + throw new \RuntimeException('Migrations failed. ' . $process->getErrorOutput()); + } + } + + /** + * @throws \Exception + */ + public function updateNodeTypesSchema(): void + { + /* + * Execute pending application migrations + */ + $this->updateSchema(); + + /* + * Update schema with new node-types + * without creating any migration + */ + $process = $this->runCommand( + 'doctrine:schema:update', + '--dump-sql --force', + ); + $process->run(); + + if ($process->wait() === 0) { + $this->logger->info('DB schema has been updated.'); + } else { + throw new \RuntimeException('DB schema update failed. ' . $process->getErrorOutput()); + } + } + + private function runCommand( + string $command, + string $args = '' + ): Process { + $args .= ' --no-interaction'; + $args .= ' --quiet'; + + $process = Process::fromShellCommandline( + 'php bin/console ' . $command . ' ' . $args + ); + $process->setWorkingDirectory($this->projectDir); + $process->setTty(false); + return $process; + } +} diff --git a/lib/RoadizCoreBundle/src/Document/DocumentFactory.php b/lib/RoadizCoreBundle/src/Document/DocumentFactory.php new file mode 100644 index 00000000..5f088b40 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Document/DocumentFactory.php @@ -0,0 +1,46 @@ +managerRegistry = $managerRegistry; + } + + protected function persistDocument(DocumentInterface $document): void + { + $this->managerRegistry->getManagerForClass(Document::class)->persist($document); + } + + /** + * @inheritDoc + */ + protected function createDocument(): DocumentInterface + { + return new Document(); + } +} diff --git a/lib/RoadizCoreBundle/src/Document/DocumentFinder.php b/lib/RoadizCoreBundle/src/Document/DocumentFinder.php new file mode 100644 index 00000000..1978621d --- /dev/null +++ b/lib/RoadizCoreBundle/src/Document/DocumentFinder.php @@ -0,0 +1,65 @@ +managerRegistry = $managerRegistry; + } + + /** + * @inheritDoc + */ + public function findAllByFilenames(array $fileNames): iterable + { + return $this->getRepository()->findBy([ + "filename" => $fileNames, + "raw" => false, + ]); + } + + /** + * @inheritDoc + */ + public function findOneByFilenames(array $fileNames): ?DocumentInterface + { + return $this->getRepository()->findOneBy([ + "filename" => $fileNames, + "raw" => false, + ]); + } + + /** + * @inheritDoc + */ + public function findOneByHashAndAlgorithm(string $hash, string $algorithm): ?DocumentInterface + { + return $this->getRepository()->findOneBy([ + "fileHash" => $hash, + "fileHashAlgorithm" => $algorithm, + ]); + } + + /** + * @return DocumentRepository + */ + protected function getRepository(): DocumentRepository + { + return $this->managerRegistry->getRepository(Document::class); + } +} diff --git a/lib/RoadizCoreBundle/src/Document/EventSubscriber/DocumentMessageDispatchSubscriber.php b/lib/RoadizCoreBundle/src/Document/EventSubscriber/DocumentMessageDispatchSubscriber.php new file mode 100644 index 00000000..4c036831 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Document/EventSubscriber/DocumentMessageDispatchSubscriber.php @@ -0,0 +1,64 @@ +bus = $bus; + } + + /** + * @inheritDoc + */ + public static function getSubscribedEvents(): array + { + return [ + // Only dispatch async message when document files are updated or created + DocumentCreatedEvent::class => ['onFilterDocumentEvent', 0], + DocumentFileUpdatedEvent::class => ['onFilterDocumentEvent', 0], + ]; + } + + public function onFilterDocumentEvent(FilterDocumentEvent $event): void + { + $document = $event->getDocument(); + if ( + $document instanceof Document && + null !== $document->getId() && + $document->isLocal() && + null !== $document->getRelativePath() + ) { + $this->bus->dispatch(new Envelope(new DocumentRawMessage($document->getId()))); + $this->bus->dispatch(new Envelope(new DocumentFilesizeMessage($document->getId()))); + $this->bus->dispatch(new Envelope(new DocumentSizeMessage($document->getId()))); + $this->bus->dispatch(new Envelope(new DocumentAverageColorMessage($document->getId()))); + $this->bus->dispatch(new Envelope(new DocumentExifMessage($document->getId()))); + $this->bus->dispatch(new Envelope(new DocumentSvgMessage($document->getId()))); + $this->bus->dispatch(new Envelope(new DocumentAudioVideoMessage($document->getId()))); + } + } +} diff --git a/lib/RoadizCoreBundle/src/Document/MediaFinder/DailymotionEmbedFinder.php b/lib/RoadizCoreBundle/src/Document/MediaFinder/DailymotionEmbedFinder.php new file mode 100644 index 00000000..71385a08 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Document/MediaFinder/DailymotionEmbedFinder.php @@ -0,0 +1,15 @@ +getRepository(Document::class) + ->findOneBy([ + 'embedId' => $embedId, + 'embedPlatform' => $embedPlatform, + ]); + + return null !== $existingDocument; + } + + /** + * @inheritDoc + */ + protected function injectMetaInDocument(ObjectManager $objectManager, DocumentInterface $document): DocumentInterface + { + $translations = $objectManager->getRepository(Translation::class)->findAll(); + + try { + /** @var Translation $translation */ + foreach ($translations as $translation) { + $documentTr = null; + if ($document instanceof Document) { + $documentTr = $document->getDocumentTranslationsByTranslation($translation)->first() ?: null; + if (null === $documentTr) { + $documentTr = new DocumentTranslation(); + $documentTr->setDocument($document); + $documentTr->setTranslation($translation); + // Need to inject translation before flushing to allow fetching existing translation + // from collection : line 43 + $document->addDocumentTranslation($documentTr); + $objectManager->persist($documentTr); + } + $documentTr->setName($this->getMediaTitle()); + $documentTr->setDescription($this->getMediaDescription()); + $documentTr->setCopyright($this->getMediaCopyright()); + } + } + } catch (APINeedsAuthentificationException $exception) { + // do no prevent from creating document if credentials are not provided. + } catch (ClientException $exception) { + // do no prevent from creating document if platform has errors, such as + // too much API usage. + } + + return $document; + } +} diff --git a/lib/RoadizCoreBundle/src/Document/MediaFinder/MixcloudEmbedFinder.php b/lib/RoadizCoreBundle/src/Document/MediaFinder/MixcloudEmbedFinder.php new file mode 100644 index 00000000..9e0cf270 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Document/MediaFinder/MixcloudEmbedFinder.php @@ -0,0 +1,12 @@ +getRepository(Translation::class)->findAll(); + + try { + /** @var Translation $translation */ + foreach ($translations as $translation) { + $documentTr = new DocumentTranslation(); + $documentTr->setDocument($document); + $documentTr->setTranslation($translation); + $documentTr->setName($this->getPodcastItemTitle($item)); + $documentTr->setDescription($this->getPodcastItemDescription($item)); + $documentTr->setCopyright($this->getPodcastItemCopyright($item)); + $objectManager->persist($documentTr); + } + } catch (ClientException $exception) { + // do no prevent from creating document if platform has errors, such as + // too much API usage. + } + } +} diff --git a/lib/RoadizCoreBundle/src/Document/MediaFinder/SoundcloudEmbedFinder.php b/lib/RoadizCoreBundle/src/Document/MediaFinder/SoundcloudEmbedFinder.php new file mode 100644 index 00000000..30daf162 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Document/MediaFinder/SoundcloudEmbedFinder.php @@ -0,0 +1,15 @@ +documentId = $documentId; + } + + /** + * @return int + */ + public function getDocumentId(): int + { + return $this->documentId; + } +} diff --git a/lib/RoadizCoreBundle/src/Document/Message/DocumentAudioVideoMessage.php b/lib/RoadizCoreBundle/src/Document/Message/DocumentAudioVideoMessage.php new file mode 100644 index 00000000..3f943283 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Document/Message/DocumentAudioVideoMessage.php @@ -0,0 +1,9 @@ +managerRegistry = $managerRegistry; + $this->logger = $messengerLogger; + $this->documentsStorage = $documentsStorage; + } + + abstract protected function supports(DocumentInterface $document): bool; + + abstract protected function processMessage(AbstractDocumentMessage $message, DocumentInterface $document): void; + + public function __invoke(AbstractDocumentMessage $message): void + { + $document = $this->managerRegistry + ->getRepository(DocumentInterface::class) + ->find($message->getDocumentId()); + + if ($document instanceof DocumentInterface && $this->supports($document)) { + try { + $this->processMessage($message, $document); + $this->managerRegistry->getManager()->flush(); + } catch (FilesystemException $exception) { + throw new RecoverableMessageHandlingException($exception->getMessage()); + } + } + } +} diff --git a/lib/RoadizCoreBundle/src/Document/MessageHandler/AbstractLockingDocumentMessageHandler.php b/lib/RoadizCoreBundle/src/Document/MessageHandler/AbstractLockingDocumentMessageHandler.php new file mode 100644 index 00000000..13988320 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Document/MessageHandler/AbstractLockingDocumentMessageHandler.php @@ -0,0 +1,52 @@ +managerRegistry + ->getRepository(DocumentInterface::class) + ->find($message->getDocumentId()); + + if ($document instanceof DocumentInterface && $this->supports($document)) { + try { + if ($this->isFileLocal($document)) { + $documentPath = $document->getMountPath(); + $resource = $this->documentsStorage->readStream($documentPath); + if (@\flock($resource, \LOCK_EX)) { + $this->processMessage($message, $document); + $this->managerRegistry->getManager()->flush(); + @\flock($resource, \LOCK_UN); + } else { + throw new RecoverableMessageHandlingException(sprintf( + '%s file is currently locked', + $documentPath + )); + } + } else { + $this->processMessage($message, $document); + $this->managerRegistry->getManager()->flush(); + } + } catch (FilesystemException $exception) { + throw new RecoverableMessageHandlingException($exception->getMessage()); + } + } + } + + protected function isFileLocal(DocumentInterface $document): bool + { + return str_starts_with($this->documentsStorage->publicUrl($document->getMountPath()), '/'); + } +} diff --git a/lib/RoadizCoreBundle/src/Document/MessageHandler/DocumentAudioVideoMessageHandler.php b/lib/RoadizCoreBundle/src/Document/MessageHandler/DocumentAudioVideoMessageHandler.php new file mode 100644 index 00000000..a9393dc0 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Document/MessageHandler/DocumentAudioVideoMessageHandler.php @@ -0,0 +1,145 @@ +ffmpegPath = $ffmpegPath; + $this->documentFactory = $documentFactory; + $this->eventDispatcher = $eventDispatcher; + } + + /** + * @param DocumentInterface $document + * @return bool + */ + protected function supports(DocumentInterface $document): bool + { + /* + * If none of AV tool are available, do not stream media for nothing. + */ + return $document->isLocal() && + ($document->isVideo() || $document->isAudio()) && + (\class_exists('getID3') || is_string($this->ffmpegPath)); + } + + protected function processMessage(AbstractDocumentMessage $message, DocumentInterface $document): void + { + /* + * This process requires document files to be locally stored! + */ + $videoPath = \tempnam(\sys_get_temp_dir(), 'video_'); + \rename($videoPath, $videoPath .= $document->getFilename()); + + /* + * Copy AV locally + */ + $videoPathResource = \fopen($videoPath, 'w'); + \stream_copy_to_stream($this->documentsStorage->readStream($document->getMountPath()), $videoPathResource); + \fclose($videoPathResource); + + $this->extractMediaMetadata($document, $videoPath); + $this->extractMediaThumbnail($document, $videoPath); + + /* + * Then delete local AV file + */ + \unlink($videoPath); + } + + protected function extractMediaMetadata(DocumentInterface $document, string $localMediaPath): void + { + if (!\class_exists('getID3')) { + return; + } + + $id3 = new \getID3(); + $fileInfo = $id3->analyze($localMediaPath); + + if ($document instanceof SizeableInterface && isset($fileInfo['video'])) { + if (isset($fileInfo['video']['resolution_x'])) { + $document->setImageWidth($fileInfo['video']['resolution_x']); + } + if (isset($fileInfo['video']['resolution_y'])) { + $document->setImageHeight($fileInfo['video']['resolution_y']); + } + } + if ($document instanceof TimeableInterface && isset($fileInfo['playtime_seconds'])) { + $document->setMediaDuration((int) floor($fileInfo['playtime_seconds'])); + } + } + + protected function extractMediaThumbnail(DocumentInterface $document, string $localMediaPath): void + { + if (!$document->isVideo() || !is_string($this->ffmpegPath)) { + return; + } + + $thumbnailPath = \tempnam(\sys_get_temp_dir(), 'thumbnail_'); + \rename($thumbnailPath, $thumbnailPath .= '.jpg'); + + $process = new Process([$this->ffmpegPath, '-y', '-i', $localMediaPath, '-vframes', '1', $thumbnailPath]); + + try { + $process->mustRun(); + $process->wait(); + + $thumbnailDocument = $this->documentFactory + ->setFolder($document->getFolders()->first() ?: null) + ->setFile(new File($thumbnailPath)) + ->getDocument(); + if ($thumbnailDocument instanceof HasThumbnailInterface && $document instanceof HasThumbnailInterface) { + $thumbnailDocument->setOriginal($document); + $document->getThumbnails()->add($thumbnailDocument); + $this->managerRegistry->getManager()->flush(); + $this->eventDispatcher->dispatch(new DocumentCreatedEvent($thumbnailDocument)); + } + } catch (ProcessFailedException $exception) { + throw new UnrecoverableMessageHandlingException( + sprintf( + 'Cannot extract thumbnail from %s video file : %s', + $localMediaPath, + $exception->getMessage() + ), + ); + } + } +} diff --git a/lib/RoadizCoreBundle/src/Document/MessageHandler/DocumentAverageColorMessageHandler.php b/lib/RoadizCoreBundle/src/Document/MessageHandler/DocumentAverageColorMessageHandler.php new file mode 100644 index 00000000..87183926 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Document/MessageHandler/DocumentAverageColorMessageHandler.php @@ -0,0 +1,65 @@ +imageManager = $imageManager; + } + + /** + * @param DocumentInterface $document + * @return bool + */ + protected function supports(DocumentInterface $document): bool + { + return $document->isLocal() && $document->isProcessable(); + } + + /** + * @param AbstractDocumentMessage $message + * @param DocumentInterface $document + * @return void + * @throws \League\Flysystem\FilesystemException + */ + protected function processMessage(AbstractDocumentMessage $message, DocumentInterface $document): void + { + if (!$document instanceof AdvancedDocumentInterface) { + return; + } + $documentStream = $this->documentsStorage->readStream($document->getMountPath()); + try { + $mediumColor = (new AverageColorResolver())->getAverageColor($this->imageManager->make($documentStream)); + $document->setImageAverageColor($mediumColor); + } catch (NotReadableException $exception) { + $this->logger->warning( + 'Document file is not a readable image.', + [ + 'path' => $document->getMountPath(), + 'message' => $exception->getMessage() + ] + ); + } + } +} diff --git a/lib/RoadizCoreBundle/src/Document/MessageHandler/DocumentExifMessageHandler.php b/lib/RoadizCoreBundle/src/Document/MessageHandler/DocumentExifMessageHandler.php new file mode 100644 index 00000000..b6f7ba92 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Document/MessageHandler/DocumentExifMessageHandler.php @@ -0,0 +1,123 @@ +isLocal()) { + return false; + } + + if ($document->getEmbedPlatform() !== "") { + return false; + } + + if ($document->getMimeType() == 'image/jpeg' || $document->getMimeType() == 'image/tiff') { + return true; + } + + return false; + } + + protected function processMessage(AbstractDocumentMessage $message, DocumentInterface $document): void + { + if (!$document instanceof Document) { + return; + } + if ( + function_exists('exif_read_data') && + $document->getDocumentTranslations()->count() === 0 + ) { + $fileStream = $this->documentsStorage->readStream($document->getMountPath()); + $exif = @\exif_read_data($fileStream, 'FILE,COMPUTED,ANY_TAG,EXIF,COMMENT'); + + if (false !== $exif) { + $copyright = $this->getCopyright($exif); + $description = $this->getDescription($exif); + + if (null !== $copyright || null !== $description) { + $this->logger->debug( + 'EXIF information available for document.', + [ + 'document' => (string)$document + ] + ); + $manager = $this->managerRegistry->getManagerForClass(DocumentTranslation::class); + $defaultTranslation = $this->managerRegistry + ->getRepository(Translation::class) + ->findDefault(); + + $documentTranslation = new DocumentTranslation(); + $documentTranslation->setCopyright($copyright) + ->setDocument($document) + ->setDescription($description) + ->setTranslation($defaultTranslation); + + $manager->persist($documentTranslation); + } + } + } + } + + /** + * @param array $exif + * @return string|null + */ + private function getCopyright(array $exif): ?string + { + foreach ($exif as $key => $section) { + if (is_array($section)) { + foreach ($section as $skey => $value) { + if (strtolower($skey) === 'copyright') { + return $value; + } + } + } + } + + return null; + } + + /** + * @param array $exif + * @return string|null + */ + private function getDescription(array $exif): ?string + { + foreach ($exif as $key => $section) { + if (is_string($section) && strtolower($key) === 'imagedescription') { + return $section; + } elseif (is_array($section)) { + if (strtolower($key) == 'comment') { + $comment = ''; + foreach ($section as $value) { + $comment .= $value . PHP_EOL; + } + return $comment; + } else { + foreach ($section as $skey => $value) { + if (strtolower($skey) == 'comment') { + return $value; + } + } + } + } + } + + return null; + } +} diff --git a/lib/RoadizCoreBundle/src/Document/MessageHandler/DocumentFilesizeMessageHandler.php b/lib/RoadizCoreBundle/src/Document/MessageHandler/DocumentFilesizeMessageHandler.php new file mode 100644 index 00000000..eda96d3f --- /dev/null +++ b/lib/RoadizCoreBundle/src/Document/MessageHandler/DocumentFilesizeMessageHandler.php @@ -0,0 +1,34 @@ +isLocal() && null !== $document->getRelativePath(); + } + + protected function processMessage(AbstractDocumentMessage $message, DocumentInterface $document): void + { + if (!$document instanceof AdvancedDocumentInterface) { + return; + } + try { + $document->setFilesize($this->documentsStorage->fileSize($document->getMountPath())); + } catch (FilesystemException $exception) { + $this->logger->warning($exception->getMessage()); + } + } +} diff --git a/lib/RoadizCoreBundle/src/Document/MessageHandler/DocumentRawMessageHandler.php b/lib/RoadizCoreBundle/src/Document/MessageHandler/DocumentRawMessageHandler.php new file mode 100644 index 00000000..8d241f4f --- /dev/null +++ b/lib/RoadizCoreBundle/src/Document/MessageHandler/DocumentRawMessageHandler.php @@ -0,0 +1,41 @@ +downscaleImageManager = $downscaleImageManager; + } + + /** + * @param DocumentInterface $document + * @return bool + */ + protected function supports(DocumentInterface $document): bool + { + return $document->isLocal() && null !== $document->getRelativePath() && $document->isProcessable(); + } + + protected function processMessage(AbstractDocumentMessage $message, DocumentInterface $document): void + { + $this->downscaleImageManager->processAndOverrideDocument($document); + } +} diff --git a/lib/RoadizCoreBundle/src/Document/MessageHandler/DocumentSizeMessageHandler.php b/lib/RoadizCoreBundle/src/Document/MessageHandler/DocumentSizeMessageHandler.php new file mode 100644 index 00000000..a64659c0 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Document/MessageHandler/DocumentSizeMessageHandler.php @@ -0,0 +1,58 @@ +imageManager = $imageManager; + } + + /** + * @param DocumentInterface $document + * @return bool + */ + protected function supports(DocumentInterface $document): bool + { + return $document->isLocal() && $document->isImage(); + } + + protected function processMessage(AbstractDocumentMessage $message, DocumentInterface $document): void + { + if (!$document instanceof SizeableInterface) { + return; + } + try { + $imageProcess = $this->imageManager->make($this->documentsStorage->readStream($document->getMountPath())); + $document->setImageWidth($imageProcess->width()); + $document->setImageHeight($imageProcess->height()); + } catch (NotReadableException $exception) { + $this->logger->warning( + 'Document file is not a readable image.', + [ + 'path' => $document->getMountPath(), + 'message' => $exception->getMessage() + ] + ); + } + } +} diff --git a/lib/RoadizCoreBundle/src/Document/MessageHandler/DocumentSvgMessageHandler.php b/lib/RoadizCoreBundle/src/Document/MessageHandler/DocumentSvgMessageHandler.php new file mode 100644 index 00000000..f49d9376 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Document/MessageHandler/DocumentSvgMessageHandler.php @@ -0,0 +1,54 @@ +isLocal() && null !== $document->getRelativePath() && $document->isSvg(); + } + + protected function processMessage(AbstractDocumentMessage $message, DocumentInterface $document): void + { + if (!$document instanceof SizeableInterface) { + return; + } + + // Create a new sanitizer instance + $sanitizer = new Sanitizer(); + $sanitizer->minify(true); + + if (!$this->documentsStorage->fileExists($document->getMountPath())) { + return; + } + + // Load the dirty svg + $dirtySVG = $this->documentsStorage->read($document->getMountPath()); + $this->documentsStorage->write($document->getMountPath(), $sanitizer->sanitize($dirtySVG)); + $this->logger->info('Svg document sanitized.'); + + /* + * Resolve SVG size + */ + try { + $svgSizeResolver = new SvgSizeResolver($document, $this->documentsStorage); + $document->setImageWidth($svgSizeResolver->getWidth()); + $document->setImageHeight($svgSizeResolver->getHeight()); + } catch (\RuntimeException $exception) { + $this->logger->error($exception->getMessage()); + } + } +} diff --git a/lib/RoadizCoreBundle/src/Document/PrivateDocumentFactory.php b/lib/RoadizCoreBundle/src/Document/PrivateDocumentFactory.php new file mode 100644 index 00000000..68713ac9 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Document/PrivateDocumentFactory.php @@ -0,0 +1,48 @@ +managerRegistry = $managerRegistry; + } + + protected function persistDocument(DocumentInterface $document): void + { + $this->managerRegistry->getManagerForClass(Document::class)->persist($document); + } + + /** + * @inheritDoc + */ + protected function createDocument(): DocumentInterface + { + $document = new Document(); + $document->setPrivate(true); + return $document; + } +} diff --git a/lib/RoadizCoreBundle/src/Entity/Attribute.php b/lib/RoadizCoreBundle/src/Entity/Attribute.php new file mode 100644 index 00000000..1d22e3dd --- /dev/null +++ b/lib/RoadizCoreBundle/src/Entity/Attribute.php @@ -0,0 +1,97 @@ + + */ + #[ + ORM\OneToMany( + mappedBy: "attribute", + targetEntity: AttributeDocuments::class, + cascade: ["persist", "merge"], + orphanRemoval: true + ), + ORM\OrderBy(["position" => "ASC"]), + Serializer\Exclude, + Serializer\Type("ArrayCollection"), + SymfonySerializer\Ignore + ] + protected Collection $attributeDocuments; + + public function __construct() + { + $this->attributeTranslations = new ArrayCollection(); + $this->attributeValues = new ArrayCollection(); + $this->attributeDocuments = new ArrayCollection(); + } + + /** + * @return Collection + */ + public function getAttributeDocuments(): Collection + { + return $this->attributeDocuments; + } + + /** + * @param Collection $attributeDocuments + * + * @return Attribute + */ + public function setAttributeDocuments(Collection $attributeDocuments): Attribute + { + $this->attributeDocuments = $attributeDocuments; + + return $this; + } + + /** + * @return Collection + */ + #[ + Serializer\VirtualProperty(), + Serializer\Groups(["attribute", "node", "nodes_sources"]), + SymfonySerializer\Groups(["attribute", "node", "nodes_sources"]), + ] + public function getDocuments(): Collection + { + /** @var Collection $values */ + $values = $this->attributeDocuments->map(function (AttributeDocuments $attributeDocuments) { + return $attributeDocuments->getDocument(); + })->filter(function (?Document $document) { + return null !== $document; + }); + return $values; // phpstan does not understand filtering null values + } +} diff --git a/lib/RoadizCoreBundle/src/Entity/AttributeDocuments.php b/lib/RoadizCoreBundle/src/Entity/AttributeDocuments.php new file mode 100644 index 00000000..fed01b4b --- /dev/null +++ b/lib/RoadizCoreBundle/src/Entity/AttributeDocuments.php @@ -0,0 +1,122 @@ +document = $document; + $this->attribute = $attribute; + } + + /** + * + */ + public function __clone() + { + if ($this->id) { + $this->id = null; + $this->attribute = null; + } + } + + /** + * Gets the value of document. + * + * @return Document|null + */ + public function getDocument(): ?Document + { + return $this->document; + } + + /** + * Sets the value of document. + * + * @param Document|null $document the document + * + * @return AttributeDocuments + */ + public function setDocument(?Document $document): AttributeDocuments + { + $this->document = $document; + + return $this; + } + + /** + * @return Attribute|null + */ + public function getAttribute(): ?Attribute + { + return $this->attribute; + } + + /** + * @param Attribute|null $attribute + * @return AttributeDocuments + */ + public function setAttribute(?Attribute $attribute): AttributeDocuments + { + $this->attribute = $attribute; + return $this; + } +} diff --git a/lib/RoadizCoreBundle/src/Entity/AttributeGroup.php b/lib/RoadizCoreBundle/src/Entity/AttributeGroup.php new file mode 100644 index 00000000..a0601959 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Entity/AttributeGroup.php @@ -0,0 +1,37 @@ +attributes = new ArrayCollection(); + $this->attributeGroupTranslations = new ArrayCollection(); + } + + protected function createAttributeGroupTranslation(): AttributeGroupTranslationInterface + { + return (new AttributeGroupTranslation())->setAttributeGroup($this); + } +} diff --git a/lib/RoadizCoreBundle/src/Entity/AttributeGroupTranslation.php b/lib/RoadizCoreBundle/src/Entity/AttributeGroupTranslation.php new file mode 100644 index 00000000..502e2ea7 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Entity/AttributeGroupTranslation.php @@ -0,0 +1,27 @@ + "exact", + "node.id" => "exact", + "node.nodeName" => "exact" + ]), + ApiFilter(BaseFilter\BooleanFilter::class, properties: [ + "node.visible" + ]) + ] + protected ?Node $node = null; + + public function __construct() + { + $this->attributeValueTranslations = new ArrayCollection(); + } + + /* + * Override method to add serialization groups and + * enable RZ\Roadiz\CoreBundle\Serializer\Normalizer\AttributeValueNormalizer + * to perform a custom serialization + */ + #[SymfonySerializer\Groups(['position', 'attribute', 'node_attributes'])] + public function getPosition(): float + { + return $this->position; + } + + /** + * @inheritDoc + */ + public function getAttributable(): ?AttributableInterface + { + return $this->node; + } + + /** + * @inheritDoc + */ + public function setAttributable(?AttributableInterface $attributable) + { + if (null === $attributable || $attributable instanceof Node) { + $this->node = $attributable; + return $this; + } + throw new \InvalidArgumentException('Attributable have to be an instance of Node.'); + } + + /** + * @return Node|null + */ + public function getNode(): ?Node + { + return $this->node; + } + + /** + * @param Node|null $node + * + * @return AttributeValue + */ + public function setNode(?Node $node): AttributeValue + { + $this->node = $node; + + return $this; + } + + /** + * After clone method. + * + * Clone current node and ist relations. + */ + public function __clone() + { + if ($this->id) { + $this->id = null; + $attributeValueTranslations = $this->getAttributeValueTranslations(); + if ($attributeValueTranslations !== null) { + $this->attributeValueTranslations = new ArrayCollection(); + /** @var AttributeValueTranslationInterface $attributeValueTranslation */ + foreach ($attributeValueTranslations as $attributeValueTranslation) { + $cloneAttributeValueTranslation = clone $attributeValueTranslation; + $cloneAttributeValueTranslation->setAttributeValue($this); + $this->attributeValueTranslations->add($cloneAttributeValueTranslation); + } + } + } + } +} diff --git a/lib/RoadizCoreBundle/src/Entity/AttributeValueTranslation.php b/lib/RoadizCoreBundle/src/Entity/AttributeValueTranslation.php new file mode 100644 index 00000000..83ed9f1d --- /dev/null +++ b/lib/RoadizCoreBundle/src/Entity/AttributeValueTranslation.php @@ -0,0 +1,23 @@ + true]), + Serializer\Groups(["custom_form", "nodes_sources"]), + SymfonySerializer\Groups(["custom_form", "nodes_sources"]), + SymfonySerializer\Ignore() + ] + private bool $open = true; + + #[ + ApiFilter(RoadizFilter\ArchiveFilter::class), + ORM\Column(name: "close_date", type: "datetime", nullable: true), + Serializer\Groups(["custom_form", "nodes_sources"]), + SymfonySerializer\Groups(["custom_form", "nodes_sources"]), + SymfonySerializer\Ignore() + ] + private ?DateTime $closeDate = null; + + /** + * @var Collection + */ + #[ + ORM\OneToMany(mappedBy: "customForm", targetEntity: CustomFormField::class, cascade: ["ALL"]), + ORM\OrderBy(["position" => "ASC"]), + Serializer\Groups(["custom_form"]), + SymfonySerializer\Groups(["custom_form"]), + SymfonySerializer\Ignore() + ] + private Collection $fields; + + /** + * @var Collection + */ + #[ + ORM\OneToMany( + mappedBy: "customForm", + targetEntity: CustomFormAnswer::class, + cascade: ["ALL"] + ), + Serializer\Exclude, + SymfonySerializer\Ignore + ] + private Collection $customFormAnswers; + + /** + * @var Collection + */ + #[ + ORM\OneToMany(mappedBy: "customForm", targetEntity: NodesCustomForms::class, fetch: "EXTRA_LAZY"), + Serializer\Exclude, + SymfonySerializer\Ignore + ] + private Collection $nodes; + + public function __construct() + { + $this->fields = new ArrayCollection(); + $this->customFormAnswers = new ArrayCollection(); + $this->nodes = new ArrayCollection(); + $this->initAbstractDateTimed(); + } + + /** + * @return string + */ + public function getDisplayName(): string + { + return $this->displayName; + } + + /** + * @param string|null $displayName + * @return $this + */ + public function setDisplayName(?string $displayName): CustomForm + { + $this->displayName = $displayName ?? ''; + $this->setName($displayName ?? ''); + + return $this; + } + + /** + * @return string|null + */ + public function getDescription(): ?string + { + return $this->description; + } + + /** + * @param string $description + * @return $this + */ + public function setDescription(string $description): CustomForm + { + $this->description = $description; + return $this; + } + + /** + * @return string|null + */ + public function getEmail(): ?string + { + return $this->email; + } + + /** + * @param string|null $email + * + * @return $this + */ + public function setEmail(?string $email): CustomForm + { + $this->email = $email; + return $this; + } + + /** + * @param bool $open + * + * @return $this + */ + public function setOpen(bool $open): CustomForm + { + $this->open = $open; + return $this; + } + + /** + * @return \DateTime|null + */ + public function getCloseDate(): ?\DateTime + { + return $this->closeDate; + } + + /** + * @param \DateTime|null $closeDate + * + * @return $this + */ + public function setCloseDate(?\DateTime $closeDate): CustomForm + { + $this->closeDate = $closeDate; + return $this; + } + + /** + * Combine open flag and closeDate to determine + * if current form is still available. + * + * @return bool + */ + #[ + Serializer\Groups(["custom_form", "nodes_sources"]), + Serializer\VirtualProperty, + SymfonySerializer\Ignore + ] + public function isFormStillOpen(): bool + { + return (null === $this->getCloseDate() || $this->getCloseDate() >= (new \DateTime('now'))) && + $this->open === true; + } + + /** + * Gets the value of color. + * + * @return string + */ + public function getColor(): string + { + return $this->color; + } + + /** + * Sets the value of color. + * + * @param string $color + * + * @return $this + */ + public function setColor(string $color): CustomForm + { + $this->color = $color; + return $this; + } + + /** + * Get every node-type fields names in + * a simple array. + * + * @return array + */ + public function getFieldsNames(): array + { + $namesArray = []; + + foreach ($this->getFields() as $field) { + $namesArray[] = $field->getName(); + } + + return $namesArray; + } + + /** + * @return Collection + */ + public function getFields(): Collection + { + return $this->fields; + } + + /** + * Get every node-type fields names in + * a simple array. + * + * @return array + */ + public function getFieldsLabels(): array + { + $namesArray = []; + + foreach ($this->getFields() as $field) { + $namesArray[] = $field->getLabel(); + } + + return $namesArray; + } + + /** + * @param CustomFormField $field + * @return CustomForm + */ + public function addField(CustomFormField $field): CustomForm + { + if (!$this->getFields()->contains($field)) { + $this->getFields()->add($field); + $field->setCustomForm($this); + } + + return $this; + } + + /** + * @param CustomFormField $field + * @return CustomForm + */ + public function removeField(CustomFormField $field): CustomForm + { + if ($this->getFields()->contains($field)) { + $this->getFields()->removeElement($field); + $field->setCustomForm(null); + } + + return $this; + } + + /** + * @return Collection + */ + public function getCustomFormAnswers(): Collection + { + return $this->customFormAnswers; + } + + /** + * @return string + */ + public function getOneLineSummary(): string + { + return $this->getId() . " — " . $this->getName() . + " — Open : " . ($this->isOpen() ? 'true' : 'false') . PHP_EOL; + } + + /** + * @return string + */ + public function getName(): string + { + return $this->name; + } + + /** + * @param string $name + * + * @return $this + */ + public function setName(string $name): CustomForm + { + $this->name = StringHandler::slugify($name); + return $this; + } + + /** + * @return bool + */ + public function isOpen(): bool + { + return $this->open; + } + + /** + * @return string|null + */ + public function getRetentionTime(): ?string + { + return $this->retentionTime; + } + + public function getRetentionTimeInterval(): ?\DateInterval + { + try { + return null !== $this->getRetentionTime() ? new \DateInterval($this->getRetentionTime()) : null; + } catch (\Exception $exception) { + return null; + } + } + + /** + * @param string|null $retentionTime + * @return CustomForm + */ + public function setRetentionTime(?string $retentionTime): CustomForm + { + $this->retentionTime = $retentionTime; + return $this; + } + + /** + * @return string $text + */ + public function getFieldsSummary(): string + { + $text = "|" . PHP_EOL; + foreach ($this->getFields() as $field) { + $text .= "|--- " . $field->getOneLineSummary(); + } + + return $text; + } + + /** + * @return Collection + */ + public function getNodes(): Collection + { + return $this->nodes; + } + + public function __clone() + { + if ($this->id) { + $this->id = null; + $suffix = "-" . uniqid(); + $this->name .= $suffix; + $this->displayName .= $suffix; + $this->customFormAnswers = new ArrayCollection(); + $fields = $this->getFields(); + $this->fields = new ArrayCollection(); + /** @var CustomFormField $field */ + foreach ($fields as $field) { + $cloneField = clone $field; + $this->fields->add($cloneField); + $cloneField->setCustomForm($this); + } + $this->nodes = new ArrayCollection(); + $this->setCreatedAt(new \DateTime()); + $this->setUpdatedAt(new \DateTime()); + } + } +} diff --git a/lib/RoadizCoreBundle/src/Entity/CustomFormAnswer.php b/lib/RoadizCoreBundle/src/Entity/CustomFormAnswer.php new file mode 100644 index 00000000..999decc6 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Entity/CustomFormAnswer.php @@ -0,0 +1,202 @@ + + */ + #[ + ORM\OneToMany( + mappedBy: "customFormAnswer", + targetEntity: CustomFormFieldAttribute::class, + cascade: ["ALL"] + ), + Serializer\Groups(["custom_form_answer"]), + SymfonySerializer\Groups(["custom_form_answer"]) + ] + private Collection $answerFields; + + #[ + ORM\ManyToOne( + targetEntity: CustomForm::class, + inversedBy: "customFormAnswers" + ), + ORM\JoinColumn(name: "custom_form_id", referencedColumnName: "id", onDelete: "CASCADE"), + Serializer\Exclude, + SymfonySerializer\Ignore + ] + private ?CustomForm $customForm = null; + + public function __construct() + { + $this->answerFields = new ArrayCollection(); + $this->submittedAt = new \DateTime(); + } + + /** + * @param CustomFormAnswer $field + * @return $this + */ + public function addAnswerField(CustomFormAnswer $field): CustomFormAnswer + { + if (!$this->getAnswers()->contains($field)) { + $this->getAnswers()->add($field); + } + + return $this; + } + + /** + * @return Collection + */ + public function getAnswers(): Collection + { + return $this->answerFields; + } + + /** + * @param CustomFormAnswer $field + * + * @return $this + */ + public function removeAnswerField(CustomFormAnswer $field): CustomFormAnswer + { + if ($this->getAnswers()->contains($field)) { + $this->getAnswers()->removeElement($field); + } + + return $this; + } + + /** + * @return CustomForm + */ + public function getCustomForm(): CustomForm + { + return $this->customForm; + } + + /** + * @param CustomForm $customForm + * @return $this + */ + public function setCustomForm(CustomForm $customForm): CustomFormAnswer + { + $this->customForm = $customForm; + return $this; + } + + /** + * @return string + */ + public function __toString(): string + { + return (string) $this->getId(); + } + + /** + * @return string + */ + public function getIp(): string + { + return $this->ip; + } + + /** + * @param string $ip + * + * @return $this + */ + public function setIp(string $ip): CustomFormAnswer + { + $this->ip = $ip; + return $this; + } + + /** + * @return \DateTime|null + */ + public function getSubmittedAt(): ?\DateTime + { + return $this->submittedAt; + } + + /** + * @param \DateTime $submittedAt + * + * @return $this + */ + public function setSubmittedAt(\DateTime $submittedAt): CustomFormAnswer + { + $this->submittedAt = $submittedAt; + return $this; + } + + /** + * @return string|null + */ + public function getEmail(): ?string + { + $attribute = $this->getAnswers()->filter(function (CustomFormFieldAttribute $attribute) { + return $attribute->getCustomFormField()->isEmail(); + })->first(); + return $attribute ? (string) $attribute->getValue() : null; + } + + /** + * @param bool $namesAsKeys Use fields name as key. Default: true + * @return array + */ + public function toArray(bool $namesAsKeys = true): array + { + $answers = []; + /** @var CustomFormFieldAttribute $answer */ + foreach ($this->answerFields as $answer) { + $field = $answer->getCustomFormField(); + if ($namesAsKeys) { + $answers[$field->getName()] = $answer->getValue(); + } else { + $answers[] = [ + 'name' => $field->getName(), + 'label' => $field->getLabel(), + 'description' => $field->getDescription(), + 'value' => $answer->getValue(), + ]; + } + } + return $answers; + } +} diff --git a/lib/RoadizCoreBundle/src/Entity/CustomFormField.php b/lib/RoadizCoreBundle/src/Entity/CustomFormField.php new file mode 100644 index 00000000..cfbdc09b --- /dev/null +++ b/lib/RoadizCoreBundle/src/Entity/CustomFormField.php @@ -0,0 +1,169 @@ + 'string.type', + AbstractField::DATETIME_T => 'date-time.type', + AbstractField::DATE_T => 'date.type', + AbstractField::TEXT_T => 'text.type', + AbstractField::MARKDOWN_T => 'markdown.type', + AbstractField::BOOLEAN_T => 'boolean.type', + AbstractField::INTEGER_T => 'integer.type', + AbstractField::DECIMAL_T => 'decimal.type', + AbstractField::EMAIL_T => 'email.type', + AbstractField::ENUM_T => 'single-choice.type', + AbstractField::MULTIPLE_T => 'multiple-choice.type', + AbstractField::COUNTRY_T => 'country.type', + AbstractField::DOCUMENTS_T => 'documents.type', + ]; + + #[ + ORM\ManyToOne(targetEntity: CustomForm::class, inversedBy: "fields"), + ORM\JoinColumn(name: "custom_form_id", referencedColumnName: "id", onDelete: "CASCADE"), + Serializer\Exclude, + SymfonySerializer\Ignore + ] + private ?CustomForm $customForm = null; + + /** + * @var Collection + */ + #[ + ORM\OneToMany(mappedBy: "customFormField", targetEntity: CustomFormFieldAttribute::class), + Serializer\Exclude, + SymfonySerializer\Ignore + ] + private Collection $customFormFieldAttributes; + + #[ + ORM\Column(name: "field_required", type: "boolean", nullable: false, options: ["default" => false]), + Serializer\Groups(["custom_form"]), + SymfonySerializer\Groups(["custom_form"]) + ] + private bool $required = false; + + public function __construct() + { + parent::__construct(); + $this->customFormFieldAttributes = new ArrayCollection(); + } + + /** + * @param string $label + * + * @return $this + */ + public function setLabel($label) + { + parent::setLabel($label); + $this->setName($label); + + return $this; + } + + /** + * @return CustomForm|null + */ + public function getCustomForm(): ?CustomForm + { + return $this->customForm; + } + + /** + * @param CustomForm|null $customForm + * + * @return $this + */ + public function setCustomForm(CustomForm $customForm = null): CustomFormField + { + $this->customForm = $customForm; + if (null !== $customForm) { + $this->customForm->addField($this); + } + + return $this; + } + + /** + * @return Collection + */ + public function getCustomFormFieldAttribute(): Collection + { + return $this->customFormFieldAttributes; + } + + /** + * @return bool $isRequired + */ + public function isRequired(): bool + { + return $this->required; + } + + /** + * @param bool $required + * + * @return $this + */ + public function setRequired(bool $required): CustomFormField + { + $this->required = $required; + return $this; + } + + /** + * @return string + */ + public function getOneLineSummary(): string + { + return $this->getId() . " — " . $this->getName() . " — " . $this->getLabel() . PHP_EOL; + } + + /** + * @return string + */ + public function __toString(): string + { + return (string) $this->getId(); + } + + public function __clone() + { + if ($this->id) { + $this->id = null; + $this->customForm = null; + $this->customFormFieldAttributes = new ArrayCollection(); + } + } +} diff --git a/lib/RoadizCoreBundle/src/Entity/CustomFormFieldAttribute.php b/lib/RoadizCoreBundle/src/Entity/CustomFormFieldAttribute.php new file mode 100644 index 00000000..5abfdd80 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Entity/CustomFormFieldAttribute.php @@ -0,0 +1,159 @@ + + */ + #[ + ORM\ManyToMany(targetEntity: "RZ\Roadiz\CoreBundle\Entity\Document", inversedBy: "customFormFieldAttributes"), + ORM\JoinTable(name: "custom_form_answers_documents"), + ORM\JoinColumn(name: "customformfieldattribute_id", onDelete: "CASCADE"), + ORM\InverseJoinColumn(name: "document_id", onDelete: "CASCADE") + ] + protected Collection $documents; + + #[ORM\Column(type: "text", nullable: true)] + protected ?string $value = null; + + public function __construct() + { + $this->documents = new ArrayCollection(); + } + + /** + * @return string|null $value + * @throws \Exception + */ + public function getValue(): ?string + { + if ($this->getCustomFormField()->isDocuments()) { + return implode(', ', $this->getDocuments()->map(function (Document $document) { + return $document->getRelativePath(); + })->toArray()); + } + if ($this->getCustomFormField()->isDate()) { + return (new \DateTime($this->value))->format('Y-m-d'); + } + if ($this->getCustomFormField()->isDateTime()) { + return (new \DateTime($this->value))->format('Y-m-d H:i:s'); + } + return $this->value; + } + + /** + * @param string|null $value + * @return $this + */ + public function setValue(?string $value): CustomFormFieldAttribute + { + $this->value = $value; + return $this; + } + + /** + * Gets the value of customFormAnswer. + * + * @return CustomFormAnswer|null + */ + public function getCustomFormAnswer(): ?CustomFormAnswer + { + return $this->customFormAnswer; + } + + /** + * Sets the value of customFormAnswer. + * + * @param CustomFormAnswer $customFormAnswer the custom form answer + * + * @return self + */ + public function setCustomFormAnswer(CustomFormAnswer $customFormAnswer): CustomFormFieldAttribute + { + $this->customFormAnswer = $customFormAnswer; + + return $this; + } + + /** + * @return string + * @throws \Exception + */ + public function __toString(): string + { + return $this->getValue() ?? ''; + } + + /** + * @return CustomFormField|null + */ + public function getCustomFormField(): ?CustomFormField + { + return $this->customFormField; + } + + /** + * Sets the value of customFormField. + * + * @param CustomFormField $customFormField the custom form field + * @return self + */ + public function setCustomFormField(CustomFormField $customFormField): CustomFormFieldAttribute + { + $this->customFormField = $customFormField; + + return $this; + } + + /** + * @return Collection + */ + public function getDocuments(): Collection + { + return $this->documents; + } + + /** + * @param Collection $documents + * + * @return CustomFormFieldAttribute + */ + public function setDocuments(Collection $documents): CustomFormFieldAttribute + { + $this->documents = $documents; + + return $this; + } +} diff --git a/lib/RoadizCoreBundle/src/Entity/Document.php b/lib/RoadizCoreBundle/src/Entity/Document.php new file mode 100644 index 00000000..73436130 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Entity/Document.php @@ -0,0 +1,802 @@ + "include_null_before", + "copyrightValidUntil" => "include_null_after" + ]), + ApiFilter(CopyrightValidFilter::class) +] +class Document extends AbstractDateTimed implements AdvancedDocumentInterface, HasThumbnailInterface, TimeableInterface, DisplayableInterface, FileHashInterface +{ + use DocumentTrait; + + /** + * @var \DateTime|null Null value is included in before filters + */ + #[ORM\Column(name: 'copyright_valid_since', type: 'datetime', nullable: true)] + #[SymfonySerializer\Groups(['document_copyright'])] + #[Serializer\Groups(['document_copyright'])] + protected ?\DateTime $copyrightValidSince = null; + + /** + * @var \DateTime|null Null value is always included in after filters + */ + #[ORM\Column(name: 'copyright_valid_until', type: 'datetime', nullable: true)] + #[SymfonySerializer\Groups(['document_copyright'])] + #[Serializer\Groups(['document_copyright'])] + protected ?\DateTime $copyrightValidUntil = null; + + #[ORM\ManyToOne( + targetEntity: Document::class, + cascade: ['all'], + fetch: 'EXTRA_LAZY', + inversedBy: 'downscaledDocuments' + )] + #[ORM\JoinColumn(name: 'raw_document', referencedColumnName: 'id', onDelete: 'SET NULL')] + #[SymfonySerializer\Ignore] + #[Serializer\Exclude] + protected ?DocumentInterface $rawDocument = null; + + #[ + SymfonySerializer\Ignore, + Serializer\Groups(["document"]), + Serializer\Type("bool"), + ORM\Column(name: 'raw', type: 'boolean', nullable: false, options: ['default' => false]) + ] + protected bool $raw = false; + + #[ORM\Column(name: 'embedId', type: 'string', unique: false, nullable: true)] + #[SymfonySerializer\Groups(['document', 'document_display', 'nodes_sources', 'tag', 'attribute'])] + #[Serializer\Groups(['document', 'document_display', 'nodes_sources', 'tag', 'attribute'])] + #[Serializer\Type("string")] + protected ?string $embedId = null; + + #[ORM\Column(name: 'file_hash', type: 'string', length: 64, unique: false, nullable: true)] + #[SymfonySerializer\Ignore] + #[Serializer\Exclude] + #[Serializer\Type('string')] + protected ?string $fileHash = null; + + #[ORM\Column(name: 'file_hash_algorithm', type: 'string', length: 15, unique: false, nullable: true)] + #[SymfonySerializer\Ignore] + #[Serializer\Exclude] + #[Serializer\Type('string')] + protected ?string $fileHashAlgorithm = null; + + #[ApiFilter(BaseFilter\SearchFilter::class, strategy: "exact")] + #[ApiFilter(RoadizFilter\NotFilter::class)] + #[ORM\Column(name: 'embedPlatform', type: 'string', unique: false, nullable: true)] + #[SymfonySerializer\Groups(['document', 'document_display', 'nodes_sources', 'tag', 'attribute'])] + #[Serializer\Groups(['document', 'document_display', 'nodes_sources', 'tag', 'attribute'])] + #[Serializer\Type('string')] + protected ?string $embedPlatform = null; + /** + * @var Collection + */ + #[ORM\OneToMany(mappedBy: 'document', targetEntity: NodesSourcesDocuments::class)] + #[SymfonySerializer\Ignore] + #[Serializer\Exclude] + protected Collection $nodesSourcesByFields; + /** + * @var Collection + */ + #[ORM\OneToMany(mappedBy: 'document', targetEntity: TagTranslationDocuments::class)] + #[SymfonySerializer\Ignore] + #[Serializer\Exclude] + protected Collection $tagTranslations; + /** + * @var Collection + */ + #[ORM\OneToMany(mappedBy: 'document', targetEntity: AttributeDocuments::class)] + #[SymfonySerializer\Ignore] + #[Serializer\Exclude] + protected Collection $attributeDocuments; + /** + * @var Collection + */ + #[ORM\ManyToMany(targetEntity: CustomFormFieldAttribute::class, mappedBy: 'documents')] + #[SymfonySerializer\Ignore] + #[Serializer\Exclude] + protected Collection $customFormFieldAttributes; + /** + * @var Collection + */ + #[ORM\JoinTable(name: 'documents_folders')] + #[ORM\ManyToMany(targetEntity: Folder::class, mappedBy: 'documents')] + #[SymfonySerializer\Ignore] + protected Collection $folders; + /** + * @var Collection + */ + #[ORM\OneToMany( + mappedBy: 'document', + targetEntity: DocumentTranslation::class, + fetch: 'EAGER', + orphanRemoval: true + )] + #[SymfonySerializer\Ignore] + #[Serializer\Groups(['document', 'nodes_sources', 'tag', 'attribute'])] + #[Serializer\Type('ArrayCollection')] + protected Collection $documentTranslations; + /** + * @var string|null + */ + #[ApiFilter(BaseFilter\SearchFilter::class, strategy: "partial")] + #[ORM\Column(name: 'filename', type: 'string', nullable: true)] + #[SymfonySerializer\Ignore] + #[Serializer\Groups(['document', 'nodes_sources', 'tag', 'attribute'])] + #[Serializer\Type('string')] + private ?string $filename = null; + /** + * @var string|null + */ + #[ApiFilter(BaseFilter\SearchFilter::class, strategy: "exact")] + #[ApiFilter(RoadizFilter\NotFilter::class)] + #[ORM\Column(name: 'mime_type', type: 'string', nullable: true)] + #[SymfonySerializer\Groups(['document', 'document_display', 'nodes_sources', 'tag', 'attribute'])] + #[Serializer\Groups(['document', 'document_display', 'nodes_sources', 'tag', 'attribute'])] + #[Serializer\Type('string')] + private ?string $mimeType = null; + /** + * @var Collection + */ + #[ORM\OneToMany(mappedBy: 'rawDocument', targetEntity: Document::class, fetch: 'EXTRA_LAZY')] + #[SymfonySerializer\Ignore] + #[Serializer\Exclude] + private Collection $downscaledDocuments; + /** + * @var string + */ + #[ORM\Column(type: 'string')] + #[SymfonySerializer\Ignore] + #[Serializer\Groups(['document', 'nodes_sources', 'tag', 'attribute'])] + #[Serializer\Type('string')] + private string $folder = ''; + /** + * @var bool + */ + #[ORM\Column(type: 'boolean', nullable: false, options: ['default' => false])] + #[SymfonySerializer\Ignore] + #[Serializer\Groups(['document', 'document_private', 'nodes_sources', 'tag', 'attribute'])] + #[Serializer\Type('bool')] + private bool $private = false; + /** + * @var integer + */ + #[ORM\Column(name: 'imageWidth', type: 'integer', nullable: false, options: ['default' => 0])] + #[SymfonySerializer\Groups(['document', 'document_display', 'nodes_sources', 'tag', 'attribute'])] + #[Serializer\Groups(['document', 'document_display', 'nodes_sources', 'tag', 'attribute'])] + #[Serializer\Type('int')] + private int $imageWidth = 0; + /** + * @var integer + */ + #[ORM\Column(name: 'imageHeight', type: 'integer', nullable: false, options: ['default' => 0])] + #[SymfonySerializer\Groups(['document', 'document_display', 'nodes_sources', 'tag', 'attribute'])] + #[Serializer\Groups(['document', 'document_display', 'nodes_sources', 'tag', 'attribute'])] + #[Serializer\Type('int')] + private int $imageHeight = 0; + /** + * @var integer + */ + #[ORM\Column(name: 'duration', type: 'integer', nullable: false, options: ['default' => 0])] + #[SymfonySerializer\Groups(['document', 'document_display', 'nodes_sources', 'tag', 'attribute'])] + #[Serializer\Groups(['document', 'document_display', 'nodes_sources', 'tag', 'attribute'])] + #[Serializer\Type('int')] + private int $mediaDuration = 0; + /** + * @var string|null + */ + #[ORM\Column(name: 'average_color', type: 'string', length: 7, unique: false, nullable: true)] + #[SymfonySerializer\Groups(['document', 'document_display', 'nodes_sources', 'tag', 'attribute'])] + #[Serializer\Groups(['document', 'document_display', 'nodes_sources', 'tag', 'attribute'])] + #[Serializer\Type('string')] + private ?string $imageAverageColor = null; + /** + * @var int|null The filesize in bytes. + */ + #[ORM\Column(name: 'filesize', type: 'integer', unique: false, nullable: true)] + #[SymfonySerializer\Groups(['document_filesize'])] + #[Serializer\Groups(['document', 'document_display', 'nodes_sources', 'tag', 'attribute'])] + #[Serializer\Type('int')] + private ?int $filesize = null; + + /** + * @var Collection + */ + #[ORM\OneToMany(mappedBy: 'original', targetEntity: Document::class, fetch: 'EXTRA_LAZY')] + #[SymfonySerializer\Ignore] + #[Serializer\Groups(['document_thumbnails'])] + #[Serializer\Type('ArrayCollection')] + private Collection $thumbnails; + + /** + * @var Document|null + */ + #[ORM\ManyToOne(targetEntity: Document::class, fetch: 'EXTRA_LAZY', inversedBy: 'thumbnails')] + #[ORM\JoinColumn(name: 'original', nullable: true, onDelete: 'SET NULL')] + #[SymfonySerializer\Groups(['document_original'])] + #[SymfonySerializer\MaxDepth(1)] + #[Serializer\Groups(['document_original'])] + #[Serializer\MaxDepth(1)] + #[Serializer\Type('RZ\Roadiz\CoreBundle\Entity\Document')] + private ?DocumentInterface $original = null; + + public function __construct() + { + $this->initAbstractDateTimed(); + $this->initDocumentTrait(); + + $this->folders = new ArrayCollection(); + $this->downscaledDocuments = new ArrayCollection(); + $this->documentTranslations = new ArrayCollection(); + $this->nodesSourcesByFields = new ArrayCollection(); + $this->tagTranslations = new ArrayCollection(); + $this->attributeDocuments = new ArrayCollection(); + $this->customFormFieldAttributes = new ArrayCollection(); + $this->thumbnails = new ArrayCollection(); + } + + /** + * @return string|null + */ + public function getMimeType(): ?string + { + return $this->mimeType; + } + + public function setMimeType(?string $mimeType): static + { + $this->mimeType = $mimeType; + return $this; + } + + /** + * @return string + */ + public function getFolder(): string + { + return $this->folder; + } + + public function setFolder(string $folder): static + { + $this->folder = $folder; + return $this; + } + + public function isPrivate(): bool + { + return $this->private; + } + + public function setPrivate(bool $private): static + { + $this->private = $private; + if (null !== $raw = $this->getRawDocument()) { + $raw->setPrivate($private); + } + + return $this; + } + + #[SymfonySerializer\Ignore] + public function getRawDocument(): ?DocumentInterface + { + return $this->rawDocument; + } + + public function setRawDocument(DocumentInterface $rawDocument = null): static + { + if (null === $rawDocument || $rawDocument instanceof Document) { + $this->rawDocument = $rawDocument; + } + return $this; + } + + /** + * @return Collection + */ + #[SymfonySerializer\Ignore] + public function getNodesSourcesByFields(): Collection + { + return $this->nodesSourcesByFields; + } + + /** + * @return Collection + */ + #[SymfonySerializer\Ignore] + public function getTagTranslations(): Collection + { + return $this->tagTranslations; + } + + /** + * @return Collection + */ + #[SymfonySerializer\Ignore] + public function getAttributeDocuments(): Collection + { + return $this->attributeDocuments; + } + + public function addFolder(FolderInterface $folder): static + { + if (!$this->getFolders()->contains($folder)) { + $this->folders->add($folder); + $folder->addDocument($this); + } + + return $this; + } + + /** + * @return Collection + */ + #[SymfonySerializer\Ignore] + public function getFolders(): Collection + { + return $this->folders; + } + + public function setFolders(Collection $folders): static + { + $this->folders = $folders; + + return $this; + } + + public function removeFolder(FolderInterface $folder): static + { + if ($this->getFolders()->contains($folder)) { + $this->folders->removeElement($folder); + $folder->removeDocument($this); + } + + return $this; + } + + /** + * @param TranslationInterface $translation + * @return Collection + */ + #[SymfonySerializer\Ignore] + public function getDocumentTranslationsByTranslation(TranslationInterface $translation): Collection + { + $criteria = Criteria::create(); + $criteria->where(Criteria::expr()->eq('translation', $translation)); + + return $this->documentTranslations->matching($criteria); + } + + /** + * @param DocumentTranslation $documentTranslation + * @return $this + */ + public function addDocumentTranslation(DocumentTranslation $documentTranslation): static + { + if (!$this->getDocumentTranslations()->contains($documentTranslation)) { + $this->documentTranslations->add($documentTranslation); + } + + return $this; + } + + /** + * @return Collection + */ + public function getDocumentTranslations(): Collection + { + return $this->documentTranslations; + } + + /** + * @return bool + */ + #[SymfonySerializer\Ignore] + public function hasTranslations(): bool + { + return $this->getDocumentTranslations()->count() > 0; + } + + /** + * Is document a raw one. + * + * @return bool + */ + public function isRaw(): bool + { + return $this->raw; + } + + public function setRaw(bool $raw): static + { + $this->raw = $raw; + return $this; + } + + /** + * Gets the downscaledDocument. + * + * @return DocumentInterface|null + */ + #[SymfonySerializer\Ignore] + public function getDownscaledDocument(): ?DocumentInterface + { + return $this->downscaledDocuments->first() ?: null; + } + + /** + * @return float|null + */ + #[SymfonySerializer\Ignore] + public function getImageRatio(): ?float + { + if ($this->getImageWidth() > 0 && $this->getImageHeight() > 0) { + return $this->getImageWidth() / $this->getImageHeight(); + } + return null; + } + + /** + * @return int + */ + public function getImageWidth(): int + { + return $this->imageWidth; + } + + public function setImageWidth(int $imageWidth): static + { + $this->imageWidth = $imageWidth; + + return $this; + } + + /** + * @return int + */ + public function getImageHeight(): int + { + return $this->imageHeight; + } + + public function setImageHeight(int $imageHeight): static + { + $this->imageHeight = $imageHeight; + + return $this; + } + + /** + * @return int + */ + public function getMediaDuration(): int + { + return $this->mediaDuration; + } + + public function setMediaDuration(int $duration): static + { + $this->mediaDuration = $duration; + return $this; + } + + /** + * @return string|null + */ + public function getImageAverageColor(): ?string + { + return $this->imageAverageColor; + } + + public function setImageAverageColor(?string $imageAverageColor): static + { + $this->imageAverageColor = $imageAverageColor; + return $this; + } + + /** + * @return int|null + */ + public function getFilesize(): ?int + { + return $this->filesize; + } + + public function setFilesize(?int $filesize): static + { + $this->filesize = $filesize; + return $this; + } + + #[ + Serializer\Groups(["document", "document_display", "nodes_sources", "tag", "attribute"]), + Serializer\Type("string"), + Serializer\VirtualProperty, + Serializer\SerializedName("alt"), + SymfonySerializer\Groups(["document", "document_display", "nodes_sources", "tag", "attribute"]), + SymfonySerializer\SerializedName("alt"), + ] + public function getAlternativeText(): string + { + $documentTranslation = $this->getDocumentTranslations()->first(); + return $documentTranslation && !empty($documentTranslation->getName()) ? + $documentTranslation->getName() : + $this->getFilename(); + } + + public function __clone() + { + if ($this->id) { + $this->id = null; + $this->rawDocument = null; + } + } + + /** + * @return bool + */ + #[SymfonySerializer\Groups(['document'])] + #[Serializer\Groups(['document'])] + #[Serializer\VirtualProperty] + public function isThumbnail(): bool + { + return $this->getOriginal() !== null; + } + + /** + * @return HasThumbnailInterface|null + */ + #[SymfonySerializer\Ignore] + public function getOriginal(): ?HasThumbnailInterface + { + return $this->original; + } + + public function setOriginal(?HasThumbnailInterface $original): static + { + if (null === $original || ($original !== $this && $original instanceof Document)) { + $this->original = $original; + } + + return $this; + } + + /** + * @return bool + */ + #[SymfonySerializer\Groups(['document'])] + #[Serializer\Groups(['document'])] + #[Serializer\VirtualProperty] + public function hasThumbnails(): bool + { + return $this->getThumbnails()->count() > 0; + } + + /** + * @return Collection + */ + public function getThumbnails(): Collection + { + return $this->thumbnails; + } + + public function setThumbnails(Collection $thumbnails): static + { + if ($this->thumbnails->count()) { + /** @var HasThumbnailInterface $thumbnail */ + foreach ($this->thumbnails as $thumbnail) { + $thumbnail->setOriginal(null); + } + } + $this->thumbnails = $thumbnails->filter(function (HasThumbnailInterface $thumbnail) { + return $thumbnail !== $this; + }); + /** @var HasThumbnailInterface $thumbnail */ + foreach ($this->thumbnails as $thumbnail) { + $thumbnail->setOriginal($this); + } + + return $this; + } + + /** + * @return DocumentInterface|null + */ + #[SymfonySerializer\Groups(['document_thumbnails'])] + #[SymfonySerializer\SerializedName('thumbnail')] + #[SymfonySerializer\MaxDepth(1)] + #[Serializer\MaxDepth(1)] + public function getFirstThumbnail(): ?DocumentInterface + { + if ($this->isEmbed() || !$this->isImage()) { + return $this->getThumbnails()->first() ?: null; + } + return null; + } + + /** + * @return bool + */ + public function needsThumbnail(): bool + { + return !$this->isProcessable(); + } + + /** + * @return string|null + */ + public function getFileHash(): ?string + { + return $this->fileHash; + } + + public function setFileHash(?string $hash): static + { + $this->fileHash = $hash; + return $this; + } + + /** + * @return string|null + */ + public function getFileHashAlgorithm(): ?string + { + return $this->fileHashAlgorithm; + } + + public function setFileHashAlgorithm(?string $algorithm): static + { + $this->fileHashAlgorithm = $algorithm; + return $this; + } + + /** + * @return \DateTime|null + */ + public function getCopyrightValidSince(): ?\DateTime + { + return $this->copyrightValidSince; + } + + public function setCopyrightValidSince(?\DateTime $copyrightValidSince): static + { + $this->copyrightValidSince = $copyrightValidSince; + return $this; + } + + /** + * @return \DateTime|null + */ + public function getCopyrightValidUntil(): ?\DateTime + { + return $this->copyrightValidUntil; + } + + public function setCopyrightValidUntil(?\DateTime $copyrightValidUntil): static + { + $this->copyrightValidUntil = $copyrightValidUntil; + return $this; + } + + /** + * @return string + */ + public function __toString(): string + { + if (!empty($this->getFilename())) { + return $this->getFilename(); + } + $translation = $this->getDocumentTranslations()->first(); + if (false !== $translation && !empty($translation->getName())) { + return $translation->getName(); + } + if (!empty($this->getEmbedPlatform())) { + return $this->getEmbedPlatform() . ' (' . $this->getEmbedId() . ')'; + } + return (string) $this->getId(); + } + + /** + * @return string + */ + public function getFilename(): string + { + return $this->filename ?? ''; + } + + public function setFilename(string $filename): static + { + $this->filename = StringHandler::cleanForFilename($filename); + + return $this; + } + + + public function getEmbedPlatform(): ?string + { + return $this->embedPlatform; + } + + public function setEmbedPlatform(?string $embedPlatform): static + { + $this->embedPlatform = $embedPlatform; + return $this; + } + + public function getEmbedId(): ?string + { + return $this->embedId; + } + + public function setEmbedId(?string $embedId): static + { + $this->embedId = $embedId; + return $this; + } +} diff --git a/lib/RoadizCoreBundle/src/Entity/DocumentTranslation.php b/lib/RoadizCoreBundle/src/Entity/DocumentTranslation.php new file mode 100644 index 00000000..0dd1f9db --- /dev/null +++ b/lib/RoadizCoreBundle/src/Entity/DocumentTranslation.php @@ -0,0 +1,173 @@ +name; + } + + /** + * @param string|null $name + * + * @return $this + */ + public function setName(?string $name): DocumentTranslation + { + $this->name = $name; + return $this; + } + + /** + * @return string + */ + public function getDescription(): ?string + { + return $this->description; + } + + /** + * @param string|null $description + * + * @return $this + */ + public function setDescription(?string $description): DocumentTranslation + { + $this->description = $description; + return $this; + } + + /** + * @return string|null + */ + public function getCopyright(): ?string + { + return $this->copyright; + } + + /** + * @param string|null $copyright + * + * @return $this + */ + public function setCopyright(?string $copyright): DocumentTranslation + { + $this->copyright = $copyright; + + return $this; + } + + /** + * @return string|null + */ + public function getExternalUrl(): ?string + { + return $this->externalUrl; + } + + /** + * @param string|null $externalUrl + * @return DocumentTranslation + */ + public function setExternalUrl(?string $externalUrl): DocumentTranslation + { + $this->externalUrl = $externalUrl; + return $this; + } + + /** + * @return TranslationInterface + */ + public function getTranslation(): TranslationInterface + { + return $this->translation; + } + + /** + * @param TranslationInterface $translation + * @return $this + */ + public function setTranslation(TranslationInterface $translation): DocumentTranslation + { + $this->translation = $translation; + + return $this; + } + + /** + * @return DocumentInterface + */ + public function getDocument(): DocumentInterface + { + return $this->document; + } + + /** + * @param DocumentInterface $document + * @return $this + */ + public function setDocument(DocumentInterface $document) + { + $this->document = $document; + return $this; + } +} diff --git a/lib/RoadizCoreBundle/src/Entity/Folder.php b/lib/RoadizCoreBundle/src/Entity/Folder.php new file mode 100644 index 00000000..76788a55 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Entity/Folder.php @@ -0,0 +1,349 @@ + "exact", + "parent.folderName" => "exact" + ])] + #[ORM\ManyToOne(targetEntity: Folder::class, inversedBy: 'children')] + #[ORM\JoinColumn(name: 'parent_id', referencedColumnName: 'id', onDelete: 'CASCADE')] + #[Serializer\Groups(['folder_parent'])] + #[SymfonySerializer\Groups(['folder_parent'])] + #[SymfonySerializer\MaxDepth(1)] + protected ?Folder $parent = null; + + /** + * @var Collection + */ + #[ORM\OneToMany(mappedBy: 'parent', targetEntity: Folder::class, orphanRemoval: true)] + #[ORM\OrderBy(['position' => 'ASC'])] + #[SymfonySerializer\Groups(['folder_children'])] + #[Serializer\Groups(['folder_children'])] + #[SymfonySerializer\MaxDepth(1)] + protected Collection $children; + + /** + * @var Collection + */ + #[ORM\JoinTable(name: 'documents_folders')] + #[ORM\JoinColumn(name: 'folder_id', referencedColumnName: 'id')] + #[ORM\InverseJoinColumn(name: 'document_id', referencedColumnName: 'id')] + #[ORM\ManyToMany(targetEntity: DocumentInterface::class, inversedBy: 'folders')] + #[SymfonySerializer\Ignore] + #[Serializer\Exclude] + /** @phpstan-ignore-next-line */ + protected Collection $documents; + + /** + * @var string + * @Serializer\Groups({"folder", "folder_color"}) + * @Serializer\Type("string") + */ + #[ORM\Column( + name: 'color', + type: 'string', + length: 7, + unique: false, + nullable: false, + options: ['default' => '#000000'] + )] + #[SymfonySerializer\Groups(['folder', 'folder_color'])] + protected string $color = '#000000'; + + #[ApiFilter(BaseFilter\SearchFilter::class, strategy: "partial")] + #[ORM\Column(name: 'folder_name', type: 'string', unique: true, nullable: false)] + #[Serializer\Groups(['folder', 'document_folders'])] + #[SymfonySerializer\Groups(['folder', 'document_folders'])] + #[SymfonySerializer\SerializedName('slug')] + #[Assert\NotBlank] + #[Assert\NotNull] + #[Assert\Length(max: 250)] + private string $folderName = ''; + + #[SymfonySerializer\Ignore] + #[Serializer\Exclude] + private string $dirtyFolderName = ''; + + #[ApiFilter(BaseFilter\BooleanFilter::class)] + #[ORM\Column(type: 'boolean', nullable: false, options: ['default' => true])] + #[SymfonySerializer\Groups(['folder', 'document_folders'])] + #[Serializer\Groups(['folder', 'document_folders'])] + private bool $visible = true; + + #[ApiFilter(BaseFilter\BooleanFilter::class)] + #[ORM\Column(type: 'boolean', nullable: false, options: ['default' => false])] + #[SymfonySerializer\Groups(['folder'])] + #[Serializer\Groups(['folder'])] + #[Serializer\Type('bool')] + private bool $locked = false; + + /** + * @var Collection + */ + #[ORM\OneToMany(mappedBy: 'folder', targetEntity: FolderTranslation::class, orphanRemoval: true)] + #[SymfonySerializer\Ignore] + #[Serializer\Exclude] + private Collection $translatedFolders; + + /** + * Create a new Folder. + */ + public function __construct() + { + $this->children = new ArrayCollection(); + $this->documents = new ArrayCollection(); + $this->translatedFolders = new ArrayCollection(); + $this->initAbstractDateTimed(); + } + + /** + * @param DocumentInterface $document + * @return $this + */ + public function addDocument(DocumentInterface $document): static + { + if (!$this->getDocuments()->contains($document)) { + $this->documents->add($document); + } + + return $this; + } + + /** + * @return Collection + */ + public function getDocuments(): Collection + { + return $this->documents; + } + + /** + * @param DocumentInterface $document + * @return $this + */ + public function removeDocument(DocumentInterface $document): static + { + if ($this->getDocuments()->contains($document)) { + $this->documents->removeElement($document); + } + + return $this; + } + + /** + * @return boolean + */ + public function getVisible(): bool + { + return $this->isVisible(); + } + + /** + * @return boolean + */ + public function isVisible(): bool + { + return $this->visible; + } + + /** + * @param bool $visible + * @return $this + */ + public function setVisible(bool $visible): static + { + $this->visible = $visible; + return $this; + } + + /** + * @param TranslationInterface $translation + * @return Collection + */ + #[SymfonySerializer\Ignore] + public function getTranslatedFoldersByTranslation(TranslationInterface $translation): Collection + { + $criteria = Criteria::create(); + $criteria->where(Criteria::expr()->eq('translation', $translation)); + + return $this->translatedFolders->matching($criteria); + } + + /** + * @return string|null + * @Serializer\VirtualProperty + * @Serializer\Groups({"folder", "document_folders"}) + */ + #[SymfonySerializer\Groups(['folder', 'document_folders'])] + public function getName(): ?string + { + return $this->getTranslatedFolders()->first() ? + $this->getTranslatedFolders()->first()->getName() : + $this->getFolderName(); + } + + /** + * @return Collection + */ + public function getTranslatedFolders(): Collection + { + return $this->translatedFolders; + } + + /** + * @param Collection $translatedFolders + * @return $this + */ + public function setTranslatedFolders(Collection $translatedFolders): static + { + $this->translatedFolders = $translatedFolders; + return $this; + } + + /** + * @return string + */ + public function getFolderName(): string + { + return $this->folderName ?? ''; + } + + /** + * @param string $folderName + * @return $this + */ + public function setFolderName(string $folderName): static + { + $this->dirtyFolderName = $folderName; + $this->folderName = StringHandler::slugify($folderName); + return $this; + } + + /** + * @return string + */ + public function getDirtyFolderName(): string + { + return $this->dirtyFolderName; + } + + /** + * @param string $dirtyFolderName + * @return $this + */ + public function setDirtyFolderName(string $dirtyFolderName): static + { + $this->dirtyFolderName = $dirtyFolderName; + return $this; + } + + /** + * @return bool + */ + public function isLocked(): bool + { + return $this->locked; + } + + /** + * @param bool $locked + * @return Folder + */ + public function setLocked(bool $locked): Folder + { + $this->locked = $locked; + return $this; + } + + /** + * @return string + */ + public function getColor(): string + { + return $this->color; + } + + /** + * @param string $color + * @return Folder + */ + public function setColor(string $color): Folder + { + $this->color = $color; + return $this; + } + + /** + * Get folder full path using folder names. + * + * @return string + */ + #[SymfonySerializer\Ignore] + public function getFullPath(): string + { + $parents = $this->getParents(); + $path = []; + + foreach ($parents as $parent) { + $path[] = $parent->getFolderName(); + } + + $path[] = $this->getFolderName(); + + return implode('/', $path); + } +} diff --git a/lib/RoadizCoreBundle/src/Entity/FolderTranslation.php b/lib/RoadizCoreBundle/src/Entity/FolderTranslation.php new file mode 100644 index 00000000..eb7577a6 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Entity/FolderTranslation.php @@ -0,0 +1,116 @@ +setFolder($original); + $this->setTranslation($translation); + $this->name = $original->getDirtyFolderName() != '' ? $original->getDirtyFolderName() : $original->getFolderName(); + } + + /** + * @return string + */ + public function getName(): string + { + return $this->name ?? ''; + } + + /** + * @param string $name + * @return $this + */ + public function setName(string $name) + { + $this->name = $name; + return $this; + } + + /** + * @return Folder + */ + public function getFolder(): Folder + { + return $this->folder; + } + + /** + * @param Folder $folder + * @return FolderTranslation + */ + public function setFolder(Folder $folder): FolderTranslation + { + $this->folder = $folder; + return $this; + } + + + /** + * Gets the value of translation. + * + * @return TranslationInterface + */ + public function getTranslation(): TranslationInterface + { + return $this->translation; + } + + /** + * Sets the value of translation. + * + * @param TranslationInterface $translation the translation + * @return self + */ + public function setTranslation(TranslationInterface $translation): FolderTranslation + { + $this->translation = $translation; + return $this; + } +} diff --git a/lib/RoadizCoreBundle/src/Entity/Group.php b/lib/RoadizCoreBundle/src/Entity/Group.php new file mode 100644 index 00000000..24afd499 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Entity/Group.php @@ -0,0 +1,187 @@ + + */ + #[ORM\ManyToMany(targetEntity: User::class, mappedBy: 'groups')] + #[SymfonySerializer\Groups(['group_user'])] + #[Serializer\Groups(['group_user'])] + #[Serializer\Type("ArrayCollection")] + private Collection $users; + + /** + * @var Collection + */ + #[ORM\JoinTable(name: 'groups_roles')] + #[ORM\JoinColumn(name: 'group_id', referencedColumnName: 'id')] + #[ORM\InverseJoinColumn(name: 'role_id', referencedColumnName: 'id')] + #[ORM\ManyToMany(targetEntity: Role::class, inversedBy: 'groups', cascade: ['persist', 'merge'])] + #[SymfonySerializer\Groups(['group'])] + #[Serializer\Groups(['group'])] + #[Serializer\Type("ArrayCollection")] + private Collection $roleEntities; + + /** + * @var array|null + * @Serializer\Groups({"group", "user"}) + * @Serializer\Type("array") + */ + #[SymfonySerializer\Groups(['group', 'user'])] + private ?array $roles = null; + + public function __construct() + { + $this->roleEntities = new ArrayCollection(); + $this->users = new ArrayCollection(); + } + + /** + * @return string + */ + public function getName(): string + { + return $this->name; + } + + /** + * @param string $name + * + * @return $this + */ + public function setName(string $name) + { + $this->name = $name; + + return $this; + } + + /** + * @return Collection + */ + public function getUsers(): Collection + { + return $this->users; + } + + /** + * Get roles names as a simple array. + * + * @return string[] + */ + public function getRoles(): array + { + if ($this->roles === null) { + $this->roles = array_map(function (Role $role) { + return $role->getRole(); + }, $this->getRolesEntities()->toArray()); + } + + return $this->roles; + } + + /** + * Get roles entities. + * + * @return Collection + */ + public function getRolesEntities(): ?Collection + { + return $this->roleEntities; + } + + /** + * Get roles entities. + * + * @param Collection $roles + * + * @return Group + */ + public function setRolesEntities(Collection $roles): self + { + $this->roleEntities = $roles; + /** @var Role $role */ + foreach ($this->roleEntities as $role) { + $role->addGroup($this); + } + return $this; + } + + /** + * @param Role $role + * @return $this + * @deprecated Use addRoleEntity + */ + public function addRole(Role $role): Group + { + return $this->addRoleEntity($role); + } + + /** + * @param Role $role + * + * @return $this + */ + public function addRoleEntity(Role $role): Group + { + if (!$this->roleEntities->contains($role)) { + $this->roleEntities->add($role); + } + + return $this; + } + + /** + * @param Role $role + * @return $this + * @deprecated Use removeRoleEntity + */ + public function removeRole(Role $role): Group + { + return $this->removeRoleEntity($role); + } + + /** + * @param Role $role + * + * @return $this + */ + public function removeRoleEntity(Role $role): Group + { + if ($this->roleEntities->contains($role)) { + $this->roleEntities->removeElement($role); + } + + return $this; + } +} diff --git a/lib/RoadizCoreBundle/src/Entity/Log.php b/lib/RoadizCoreBundle/src/Entity/Log.php new file mode 100644 index 00000000..c42579f1 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Entity/Log.php @@ -0,0 +1,244 @@ +level = $level; + $this->message = $message; + $this->datetime = new \DateTime("now"); + } + + public function getUser(): ?User + { + return $this->user; + } + + /** + * @param User $user + * + * @return Log + */ + public function setUser(User $user): Log + { + $this->user = $user; + $this->username = $user->getUsername(); + return $this; + } + + /** + * @return string|null + */ + public function getUsername(): ?string + { + return $this->username; + } + + /** + * @param string|null $username + * + * @return Log + */ + public function setUsername(?string $username) + { + $this->username = $username; + + return $this; + } + + /** + * @return string + */ + public function getMessage(): string + { + return $this->message; + } + + /** + * @return int + */ + public function getLevel(): int + { + return $this->level; + } + + /** + * @return \DateTime + */ + public function getDatetime(): \DateTime + { + return $this->datetime; + } + + /** + * Get log related node-source. + * + * @return NodesSources|null + */ + public function getNodeSource(): ?NodesSources + { + return $this->nodeSource; + } + + /** + * @param NodesSources|null $nodeSource + * @return $this + */ + public function setNodeSource(?NodesSources $nodeSource): Log + { + $this->nodeSource = $nodeSource; + return $this; + } + + /** + * @return string + */ + public function getClientIp(): ?string + { + return $this->clientIp; + } + + /** + * @param string $clientIp + * @return Log + */ + public function setClientIp(?string $clientIp): Log + { + $this->clientIp = $clientIp; + return $this; + } + + /** + * @return array|null + */ + public function getAdditionalData(): ?array + { + return $this->additionalData; + } + + /** + * @param array|null $additionalData + * + * @return Log + */ + public function setAdditionalData(?array $additionalData): Log + { + $this->additionalData = $additionalData; + + return $this; + } + + /** + * @return string|null + */ + public function getChannel(): ?string + { + return $this->channel; + } + + /** + * @param string|null $channel + * + * @return Log + */ + public function setChannel(?string $channel): Log + { + $this->channel = $channel; + + return $this; + } + + #[ORM\PrePersist] + public function prePersist(): void + { + $this->datetime = new \DateTime("now"); + } +} diff --git a/lib/RoadizCoreBundle/src/Entity/LoginAttempt.php b/lib/RoadizCoreBundle/src/Entity/LoginAttempt.php new file mode 100644 index 00000000..db44dc88 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Entity/LoginAttempt.php @@ -0,0 +1,106 @@ +ipAddress = $ipAddress; + $this->username = $username; + $this->date = new \DateTimeImmutable('now'); + $this->blocksLoginUntil = new \DateTime('now'); + $this->attemptCount = 0; + } + + public function getId(): int + { + return $this->id; + } + + public function getIpAddress(): ?string + { + return $this->ipAddress; + } + + public function getDate(): \DateTimeImmutable + { + return $this->date; + } + + public function getUsername(): ?string + { + return $this->username; + } + + /** + * @return \DateTime|null + */ + public function getBlocksLoginUntil(): ?\DateTime + { + return $this->blocksLoginUntil; + } + + /** + * @param \DateTime $blocksLoginUntil + * + * @return LoginAttempt + */ + public function setBlocksLoginUntil(\DateTime $blocksLoginUntil): LoginAttempt + { + $this->blocksLoginUntil = $blocksLoginUntil; + + return $this; + } + + /** + * @return int + */ + public function getAttemptCount(): int + { + return $this->attemptCount; + } + + /** + * @return LoginAttempt + */ + public function addAttemptCount(): LoginAttempt + { + $this->attemptCount++; + return $this; + } +} diff --git a/lib/RoadizCoreBundle/src/Entity/Node.php b/lib/RoadizCoreBundle/src/Entity/Node.php new file mode 100644 index 00000000..1656e7b2 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Entity/Node.php @@ -0,0 +1,1003 @@ + 'position', + 'nodeName' => 'nodeName', + 'createdAt' => 'createdAt', + 'updatedAt' => 'updatedAt', + 'publishedAt' => 'ns.publishedAt', + ]; + + #[ORM\Column(name: 'node_name', type: 'string', unique: true)] + #[SymfonySerializer\Groups(['nodes_sources', 'nodes_sources_base', 'node', 'log_sources'])] + #[Serializer\Groups(['nodes_sources', 'nodes_sources_base', 'node', 'log_sources'])] + #[Serializer\Accessor(getter: "getNodeName", setter: "setNodeName")] + #[Assert\NotNull] + #[Assert\NotBlank] + #[Assert\Length(max: 255)] + private string $nodeName = ''; + + #[ORM\Column(name: 'dynamic_node_name', type: 'boolean', nullable: false, options: ['default' => true])] + #[SymfonySerializer\Ignore] + #[Gedmo\Versioned] + private bool $dynamicNodeName = true; + + #[ORM\Column(name: 'home', type: 'boolean', nullable: false, options: ['default' => false])] + #[SymfonySerializer\Ignore] + private bool $home = false; + + #[ORM\Column(type: 'boolean', nullable: false, options: ['default' => true])] + #[SymfonySerializer\Groups(['nodes_sources_base', 'nodes_sources', 'node'])] + #[Serializer\Groups(['nodes_sources_base', 'nodes_sources', 'node'])] + #[Gedmo\Versioned] + private bool $visible = true; + + /** + * @internal You should use node Workflow to perform change on status. + */ + #[ORM\Column(type: 'integer')] + #[Serializer\Exclude] + #[SymfonySerializer\Ignore] + private int $status = Node::DRAFT; + + #[ORM\Column(type: 'integer', nullable: false, options: ['default' => 0])] + #[Assert\GreaterThanOrEqual(value: 0)] + #[SymfonySerializer\Ignore] + #[Serializer\Exclude] + #[Gedmo\Versioned] + private int $ttl = 0; + + #[ORM\Column(type: 'boolean', nullable: false, options: ['default' => false])] + #[SymfonySerializer\Groups(['node'])] + #[Serializer\Groups(['node'])] + #[Gedmo\Versioned] + private bool $locked = false; + + /** + * @var float|string|int + */ + #[ORM\Column(type: 'decimal', precision: 2, scale: 1)] + #[SymfonySerializer\Groups(['node'])] + #[Serializer\Groups(['node'])] + #[Gedmo\Versioned] + private string|float|int $priority = 0.8; + + #[ORM\Column(name: 'hide_children', type: 'boolean', nullable: false, options: ['default' => false])] + #[SymfonySerializer\Groups(['node'])] + #[Serializer\Groups(['node'])] + #[Gedmo\Versioned] + private bool $hideChildren = false; + + #[ORM\Column(type: 'boolean', nullable: false, options: ['default' => false])] + #[SymfonySerializer\Groups(['node'])] + #[Serializer\Groups(['node'])] + #[Gedmo\Versioned] + private bool $sterile = false; + + #[ORM\Column(name: 'children_order', type: 'string')] + #[SymfonySerializer\Groups(['node'])] + #[Serializer\Groups(['node'])] + #[Gedmo\Versioned] + private string $childrenOrder = 'position'; + + #[ORM\Column(name: 'children_order_direction', type: 'string', length: 4)] + #[SymfonySerializer\Groups(['node'])] + #[Serializer\Groups(['node'])] + #[Gedmo\Versioned] + private string $childrenOrderDirection = 'ASC'; + + /** + * @var NodeTypeInterface|null + */ + #[ORM\ManyToOne(targetEntity: NodeTypeInterface::class)] + #[ORM\JoinColumn(name: 'nodeType_id', referencedColumnName: 'id', onDelete: 'CASCADE')] + #[SymfonySerializer\Groups(['node'])] + #[Serializer\Groups(['node'])] + #[SymfonySerializer\Ignore] + private ?NodeTypeInterface $nodeType = null; + + /** + * @var Node|null + */ + #[ORM\ManyToOne(targetEntity: Node::class, fetch: 'EAGER', inversedBy: 'children')] + #[ORM\JoinColumn(name: 'parent_node_id', referencedColumnName: 'id', onDelete: 'CASCADE')] + #[SymfonySerializer\Ignore] + #[Serializer\Exclude] + private ?LeafInterface $parent = null; + + /** + * @var Collection + */ + #[ORM\OneToMany(mappedBy: 'parent', targetEntity: Node::class, orphanRemoval: true)] + #[ORM\OrderBy(['position' => 'ASC'])] + #[SymfonySerializer\Groups(['node_children'])] + #[Serializer\Groups(['node_children'])] + private Collection $children; + + /** + * @var Collection + */ + #[ORM\OneToMany( + mappedBy: 'node', + targetEntity: NodesTags::class, + cascade: ['persist', 'remove'], + orphanRemoval: true + )] + #[ORM\OrderBy(['position' => 'ASC'])] + #[SymfonySerializer\Ignore] + #[Serializer\Exclude] + #[ApiFilter(BaseFilter\SearchFilter::class, properties: [ + "nodesTags.tag" => "exact", + "nodesTags.tag.tagName" => "exact", + ])] + #[ApiFilter(RoadizFilter\NotFilter::class, properties: [ + "nodesTags.tag.tagName", + ])] + # Use IntersectionFilter after SearchFilter! + #[ApiFilter(RoadizFilter\IntersectionFilter::class, properties: [ + "nodesTags.tag", + "nodesTags.tag.tagName", + ])] + private Collection $nodesTags; + + /** + * @var Collection + */ + #[ORM\OneToMany(mappedBy: 'node', targetEntity: NodesCustomForms::class, fetch: 'EXTRA_LAZY')] + #[SymfonySerializer\Ignore] + #[Serializer\Exclude] + private Collection $customForms; + + /** + * @var Collection + */ + #[ORM\JoinTable(name: 'stack_types')] + #[ORM\InverseJoinColumn(name: 'nodetype_id', onDelete: 'CASCADE')] + #[ORM\ManyToMany(targetEntity: NodeType::class)] + #[Serializer\Groups(['node'])] + #[SymfonySerializer\Groups(['node'])] + #[SymfonySerializer\Ignore] + private Collection $stackTypes; + + /** + * @var Collection + */ + #[ORM\OneToMany( + mappedBy: 'node', + targetEntity: NodesSources::class, + fetch: 'EXTRA_LAZY', + orphanRemoval: true + )] + #[Serializer\Groups(['node'])] + #[SymfonySerializer\Groups(['node'])] + #[SymfonySerializer\Ignore] + private Collection $nodeSources; + + /** + * @var Collection + */ + #[ORM\OneToMany( + mappedBy: 'nodeA', + targetEntity: NodesToNodes::class, + cascade: ['persist'], + fetch: 'LAZY', + orphanRemoval: true + )] + #[ORM\OrderBy(['position' => 'ASC'])] + #[SymfonySerializer\Ignore] + #[Serializer\Exclude] + private Collection $bNodes; + + /** + * @var Collection + */ + #[ORM\OneToMany(mappedBy: 'nodeB', targetEntity: NodesToNodes::class)] + #[SymfonySerializer\Ignore] + #[Serializer\Exclude] + private Collection $aNodes; + + /** + * @var Collection + */ + #[ORM\OneToMany(mappedBy: 'node', targetEntity: AttributeValue::class, orphanRemoval: true)] + #[ORM\OrderBy(['position' => 'ASC'])] + #[Serializer\Groups(['node_attributes'])] + #[SymfonySerializer\Groups(['node_attributes'])] + #[SymfonySerializer\MaxDepth(1)] + private Collection $attributeValues; + + /** + * Create a new empty Node according to given node-type. + */ + public function __construct(NodeTypeInterface $nodeType = null) + { + $this->nodesTags = new ArrayCollection(); + $this->children = new ArrayCollection(); + $this->nodeSources = new ArrayCollection(); + $this->stackTypes = new ArrayCollection(); + $this->customForms = new ArrayCollection(); + $this->aNodes = new ArrayCollection(); + $this->bNodes = new ArrayCollection(); + $this->attributeValues = new ArrayCollection(); + + $this->setNodeType($nodeType); + $this->initAbstractDateTimed(); + } + + /** + * @param int $status + * @return string + */ + public static function getStatusLabel($status): string + { + $nodeStatuses = [ + static::DRAFT => 'draft', + static::PENDING => 'pending', + static::PUBLISHED => 'published', + static::ARCHIVED => 'archived', + static::DELETED => 'deleted', + ]; + + if (isset($nodeStatuses[$status])) { + return $nodeStatuses[$status]; + } + + throw new \InvalidArgumentException('Status does not exist.'); + } + + /** + * Dynamic node name will be updated against default + * translated nodeSource title at each save. + * + * Disable this parameter if you need to protect your nodeName + * from title changes. + * + * @return bool + */ + public function isDynamicNodeName(): bool + { + return $this->dynamicNodeName; + } + + /** + * @param bool $dynamicNodeName + * @return $this + */ + public function setDynamicNodeName(bool $dynamicNodeName): Node + { + $this->dynamicNodeName = (bool) $dynamicNodeName; + return $this; + } + + /** + * @return bool + */ + public function isHome(): bool + { + return $this->home; + } + + /** + * @param bool $home + * @return $this + */ + public function setHome(bool $home): Node + { + $this->home = $home; + return $this; + } + + /** + * @return int + */ + public function getStatus(): int + { + return $this->status; + } + + /** + * @param int|string $status Workflow only use marking places + * @return $this + * @internal You should use node Workflow to perform change on status. + */ + public function setStatus(int|string $status): Node + { + $this->status = (int) $status; + return $this; + } + + /** + * @return int + */ + public function getTtl(): int + { + return $this->ttl; + } + + /** + * @param int $ttl + * + * @return Node + */ + public function setTtl(int $ttl): Node + { + $this->ttl = $ttl; + return $this; + } + + /** + * @return bool + */ + public function isPublished(): bool + { + return ($this->status === Node::PUBLISHED); + } + + /** + * @return bool + */ + public function isPending(): bool + { + return ($this->status === Node::PENDING); + } + + /** + * @return bool + */ + public function isDraft(): bool + { + return ($this->status === Node::DRAFT); + } + + /** + * @return bool + */ + public function isDeleted(): bool + { + return ($this->status === Node::DELETED); + } + + /** + * @return bool + */ + public function isLocked(): bool + { + return $this->locked; + } + + /** + * @param bool $locked + * @return $this + */ + public function setLocked(bool $locked): static + { + $this->locked = $locked; + return $this; + } + + /** + * @return float|string + */ + public function getPriority() + { + return $this->priority; + } + + /** + * @param float|string $priority + * @return $this + */ + public function setPriority($priority): static + { + $this->priority = $priority; + return $this; + } + + /** + * @return bool + */ + public function getHideChildren(): bool + { + return $this->hideChildren; + } + + /** + * @param bool $hideChildren + * @return $this + */ + public function setHideChildren(bool $hideChildren): static + { + $this->hideChildren = $hideChildren; + return $this; + } + + /** + * @return bool + */ + public function isHidingChildren(): bool + { + return $this->hideChildren; + } + + /** + * @param bool $hideChildren + * + * @return $this + */ + public function setHidingChildren(bool $hideChildren): static + { + $this->hideChildren = $hideChildren; + return $this; + } + + /** + * @return bool + */ + public function isArchived(): bool + { + return ($this->status === Node::ARCHIVED); + } + + /** + * @return bool + */ + public function isSterile(): bool + { + return $this->sterile; + } + + /** + * @param bool $sterile + * @return $this + */ + public function setSterile(bool $sterile): static + { + $this->sterile = $sterile; + return $this; + } + + /** + * @return string + */ + public function getChildrenOrder(): string + { + return $this->childrenOrder; + } + + /** + * @param string $childrenOrder + * @return $this + */ + public function setChildrenOrder(string $childrenOrder): static + { + $this->childrenOrder = $childrenOrder; + return $this; + } + + /** + * @return string + */ + public function getChildrenOrderDirection(): string + { + return $this->childrenOrderDirection; + } + + /** + * @param string $childrenOrderDirection + * @return $this + */ + public function setChildrenOrderDirection(string $childrenOrderDirection): static + { + $this->childrenOrderDirection = $childrenOrderDirection; + return $this; + } + + /** + * @return Collection + */ + public function getNodesTags(): Collection + { + return $this->nodesTags; + } + + /** + * @param Collection $nodesTags + * @return $this + */ + public function setNodesTags(Collection $nodesTags): static + { + foreach ($nodesTags as $singleNodesTags) { + $singleNodesTags->setNode($this); + } + $this->nodesTags = $nodesTags; + + return $this; + } + + /** + * @return Collection + */ + #[SymfonySerializer\Groups(['nodes_sources', 'nodes_sources_base', 'node'])] + #[Serializer\Groups(['nodes_sources', 'nodes_sources_base', 'node'])] + #[Serializer\VirtualProperty] + public function getTags(): Collection + { + return $this->nodesTags->map(function (NodesTags $nodesTags) { + return $nodesTags->getTag(); + }); + } + + /** + * @param iterable $tags + * + * @return $this + */ + public function setTags(iterable $tags): static + { + $this->nodesTags->clear(); + $i = 0; + foreach ($tags as $tag) { + $this->nodesTags->add( + (new NodesTags())->setNode($this)->setTag($tag)->setPosition(++$i) + ); + } + return $this; + } + + /** + * @param Tag $tag + * + * @return $this + */ + public function addTag(Tag $tag): static + { + if ( + !$this->getTags()->exists(function ($key, Tag $existingTag) use ($tag) { + return $tag->getId() === $existingTag->getId(); + }) + ) { + $last = $this->nodesTags->last(); + if (false !== $last) { + $i = $last->getPosition(); + } else { + $i = 0; + } + $this->nodesTags->add( + (new NodesTags())->setNode($this)->setTag($tag)->setPosition(++$i) + ); + } + + return $this; + } + + public function removeTag(Tag $tag): static + { + $nodeTags = $this->nodesTags->filter(function (NodesTags $existingNodesTags) use ($tag) { + return $existingNodesTags->getTag()->getId() === $tag->getId(); + }); + foreach ($nodeTags as $singleNodeTags) { + $this->nodesTags->removeElement($singleNodeTags); + } + + return $this; + } + + /** + * @return Collection + */ + public function getCustomForms(): Collection + { + return $this->customForms; + } + + /** + * @param Collection $customForms + * @return $this + */ + public function setCustomForms(Collection $customForms): static + { + $this->customForms = $customForms; + return $this; + } + + /** + * Used by generated nodes-sources. + * + * @param NodesCustomForms $nodesCustomForms + * @return $this + */ + public function addCustomForm(NodesCustomForms $nodesCustomForms): static + { + if (!$this->customForms->contains($nodesCustomForms)) { + $this->customForms->add($nodesCustomForms); + } + return $this; + } + + /** + * @param NodeType $stackType + * + * @return $this + */ + public function removeStackType(NodeType $stackType): static + { + if ($this->getStackTypes()->contains($stackType)) { + $this->getStackTypes()->removeElement($stackType); + } + + return $this; + } + + /** + * @return Collection + */ + public function getStackTypes(): Collection + { + return $this->stackTypes; + } + + /** + * @param NodeType $stackType + * + * @return $this + */ + public function addStackType(NodeType $stackType): static + { + if (!$this->getStackTypes()->contains($stackType)) { + $this->getStackTypes()->add($stackType); + } + + return $this; + } + + /** + * Get node-sources using a given translation. + * + * @param TranslationInterface $translation + * @return Collection + */ + #[SymfonySerializer\Ignore] + public function getNodeSourcesByTranslation(TranslationInterface $translation): Collection + { + return $this->nodeSources->filter(function (NodesSources $nodeSource) use ($translation) { + return $nodeSource->getTranslation()->getLocale() === $translation->getLocale(); + }); + } + + /** + * @param NodesSources $ns + * + * @return $this + */ + public function removeNodeSources(NodesSources $ns): static + { + if ($this->getNodeSources()->contains($ns)) { + $this->getNodeSources()->removeElement($ns); + } + + return $this; + } + + /** + * @return Collection + */ + public function getNodeSources(): Collection + { + return $this->nodeSources; + } + + /** + * @param NodesSources $ns + * + * @return $this + */ + public function addNodeSources(NodesSources $ns): static + { + if (!$this->getNodeSources()->contains($ns)) { + $this->getNodeSources()->add($ns); + } + + return $this; + } + + /** + * @param NodeTypeField $field + * + * @return Collection + */ + #[SymfonySerializer\Ignore] + public function getBNodesByField(NodeTypeField $field): Collection + { + $criteria = Criteria::create(); + $criteria->andWhere(Criteria::expr()->eq('field', $field)); + $criteria->orderBy(['position' => 'ASC']); + return $this->getBNodes()->matching($criteria); + } + + /** + * Return nodes related to this (B nodes). + * + * @return Collection + */ + public function getBNodes(): Collection + { + return $this->bNodes; + } + + /** + * @param ArrayCollection $bNodes + * @return $this + */ + public function setBNodes(ArrayCollection $bNodes): static + { + foreach ($this->bNodes as $bNode) { + $bNode->setNodeA(null); + } + $this->bNodes->clear(); + foreach ($bNodes as $bNode) { + if (!$this->hasBNode($bNode)) { + $this->addBNode($bNode); + } + } + return $this; + } + + public function hasBNode(NodesToNodes $bNode): bool + { + return $this->getBNodes()->exists(function ($key, NodesToNodes $element) use ($bNode) { + return $bNode->getNodeB()->getId() !== null && + $element->getNodeB()->getId() === $bNode->getNodeB()->getId() && + $element->getField()->getId() === $bNode->getField()->getId(); + }); + } + + /** + * @param NodesToNodes $bNode + * @return $this + */ + public function addBNode(NodesToNodes $bNode): static + { + if (!$this->getBNodes()->contains($bNode)) { + $this->getBNodes()->add($bNode); + $bNode->setNodeA($this); + } + return $this; + } + + public function clearBNodesForField(NodeTypeField $nodeTypeField): Node + { + $toRemoveCollection = $this->getBNodes()->filter(function (NodesToNodes $element) use ($nodeTypeField) { + return $element->getField()->getId() === $nodeTypeField->getId(); + }); + /** @var NodesToNodes $toRemove */ + foreach ($toRemoveCollection as $toRemove) { + $this->getBNodes()->removeElement($toRemove); + $toRemove->setNodeA(null); + } + return $this; + } + + /** + * Return nodes which own a relation with this (A nodes). + * + * @return Collection + */ + public function getANodes(): Collection + { + return $this->aNodes; + } + + /** + * @return string + */ + #[SymfonySerializer\Ignore] + public function getOneLineSummary(): string + { + return $this->getId() . " — " . $this->getNodeName() . " — " . $this->getNodeType()->getName() . + " — Visible : " . ($this->isVisible() ? 'true' : 'false') . PHP_EOL; + } + + /** + * @return string + */ + public function getNodeName(): string + { + return $this->nodeName; + } + + /** + * @param string $nodeName + * @return $this + */ + public function setNodeName(string $nodeName): static + { + $this->nodeName = StringHandler::slugify($nodeName); + return $this; + } + + /** + * @return NodeTypeInterface|null + */ + public function getNodeType(): ?NodeTypeInterface + { + return $this->nodeType; + } + + /** + * @param NodeTypeInterface|null $nodeType + * @return $this + */ + public function setNodeType(?NodeTypeInterface $nodeType = null): static + { + $this->nodeType = $nodeType; + return $this; + } + + /** + * @return bool + */ + public function isVisible(): bool + { + return $this->visible; + } + + /** + * @param bool $visible + * @return $this + */ + public function setVisible(bool $visible): Node + { + $this->visible = $visible; + return $this; + } + + /** + * @return string + */ + #[SymfonySerializer\Ignore] + public function getOneLineSourceSummary(): string + { + $text = "Source " . $this->getNodeSources()->first()->getId() . PHP_EOL; + + foreach ($this->getNodeType()->getFields() as $field) { + $getterName = $field->getGetterName(); + $text .= '[' . $field->getLabel() . ']: ' . $this->getNodeSources()->first()->$getterName() . PHP_EOL; + } + + return $text; + } + + /** + * After clone method. + * + * Clone current node and ist relations. + */ + public function __clone() + { + if ($this->id) { + $this->id = null; + $this->home = false; + $children = $this->getChildren(); + $this->children = new ArrayCollection(); + foreach ($children as $child) { + $cloneChild = clone $child; + $this->addChild($cloneChild); + } + + /** @var NodesTags[] $nodesTags */ + $nodesTags = $this->nodesTags->toArray(); + if ($nodesTags !== null) { + $this->nodesTags = new ArrayCollection(); + foreach ($nodesTags as $nodesTag) { + $this->addTag($nodesTag->getTag()); + } + } + $nodeSources = $this->getNodeSources(); + $this->nodeSources = new ArrayCollection(); + /** @var NodesSources $nodeSource */ + foreach ($nodeSources as $nodeSource) { + $cloneNodeSource = clone $nodeSource; + $cloneNodeSource->setNode($this); + } + + $attributeValues = $this->getAttributeValues(); + $this->attributeValues = new ArrayCollection(); + /** @var AttributeValue $attributeValue */ + foreach ($attributeValues as $attributeValue) { + $cloneAttributeValue = clone $attributeValue; + $cloneAttributeValue->setNode($this); + $this->addAttributeValue($cloneAttributeValue); + } + + // Get a random string after node-name. + // This is for safety reasons + // NodeDuplicator service will override it + $namePrefix = $this->getNodeSources()->first()->getTitle() != "" ? + $this->getNodeSources()->first()->getTitle() : + $this->nodeName; + $this->setNodeName($namePrefix . "-" . uniqid()); + $this->setCreatedAt(new \DateTime()); + $this->setUpdatedAt(new \DateTime()); + } + } + + /** + * @return string + */ + public function __toString(): string + { + return (string) $this->getId(); + } +} diff --git a/lib/RoadizCoreBundle/src/Entity/NodeType.php b/lib/RoadizCoreBundle/src/Entity/NodeType.php new file mode 100644 index 00000000..64dc504c --- /dev/null +++ b/lib/RoadizCoreBundle/src/Entity/NodeType.php @@ -0,0 +1,522 @@ + true]), + Serializer\Groups(["node_type"]), + SymfonySerializer\Groups(["node_type"]), + Serializer\Type("boolean") + ] + private bool $visible = true; + #[ + ORM\Column(type: "boolean", nullable: false, options: ["default" => false]), + Serializer\Groups(["node_type"]), + SymfonySerializer\Groups(["node_type"]), + Serializer\Type("boolean") + ] + private bool $publishable = false; + /** + * Define if this node-type produces nodes that will be + * viewable from a Controller. + * + * Typically, if a node has a URL. + */ + #[ + ORM\Column(name: "reachable", type: "boolean", nullable: false, options: ["default" => true]), + Serializer\Groups(["node_type"]), + SymfonySerializer\Groups(["node_type"]), + Serializer\Type("boolean") + ] + private bool $reachable = true; + #[ + ORM\Column(name: "hiding_nodes", type: "boolean", nullable: false, options: ["default" => false]), + Serializer\Groups(["node_type"]), + SymfonySerializer\Groups(["node_type"]), + Serializer\Type("boolean") + ] + private bool $hidingNodes = false; + #[ + ORM\Column(name: "hiding_non_reachable_nodes", type: "boolean", nullable: false, options: ["default" => false]), + Serializer\Groups(["node_type"]), + SymfonySerializer\Groups(["node_type"]), + Serializer\Type("boolean") + ] + private bool $hidingNonReachableNodes = false; + /** + * @var Collection + */ + #[ + ORM\OneToMany(mappedBy: "nodeType", targetEntity: NodeTypeField::class, cascade: ["persist", "merge"]), + ORM\OrderBy(["position" => "ASC"]), + Serializer\Groups(["node_type"]), + SymfonySerializer\Groups(["node_type"]), + Serializer\Type("ArrayCollection"), + Serializer\Accessor(getter: "getFields", setter: "setFields") + ] + private Collection $fields; + #[ + ORM\Column(name: "default_ttl", type: "integer", nullable: false, options: ["default" => 0]), + Serializer\Groups(["node_type"]), + SymfonySerializer\Groups(["node_type"]), + Serializer\Type("int"), + Assert\GreaterThanOrEqual(value: 0) + ] + private int $defaultTtl = 0; + /** + * Define if this node-type title will be indexed during its parent indexation. + */ + #[ + ORM\Column(name: "searchable", type: "boolean", nullable: false, options: ["default" => true]), + Serializer\Groups(["node_type"]), + SymfonySerializer\Groups(["node_type"]), + Serializer\Type("boolean") + ] + private bool $searchable = true; + + /** + * Create a new NodeType. + */ + public function __construct() + { + $this->fields = new ArrayCollection(); + $this->name = 'Untitled'; + $this->displayName = 'Untitled node-type'; + } + + public function getLabel(): string + { + return $this->getDisplayName(); + } + + /** + * @return string + */ + public function getDisplayName(): string + { + return $this->displayName; + } + + /** + * @param string|null $displayName + * + * @return $this + */ + public function setDisplayName(?string $displayName): NodeType + { + $this->displayName = $displayName ?? ''; + return $this; + } + + /** + * @return string|null + */ + public function getDescription(): ?string + { + return $this->description; + } + + /** + * @param string|null $description + * @return $this + */ + public function setDescription(?string $description = null): NodeType + { + $this->description = $description; + return $this; + } + + /** + * @return boolean + */ + public function isVisible(): bool + { + return $this->visible; + } + + /** + * @param boolean $visible + * @return $this + */ + public function setVisible(bool $visible): NodeType + { + $this->visible = $visible; + return $this; + } + + /** + * @return bool + */ + public function isPublishable(): bool + { + return $this->publishable; + } + + /** + * @param bool $publishable + * @return NodeType + */ + public function setPublishable(bool $publishable): NodeType + { + $this->publishable = $publishable; + return $this; + } + + /** + * @return bool + */ + public function getReachable(): bool + { + return $this->reachable; + } + + /** + * @return bool + */ + public function isReachable(): bool + { + return $this->getReachable(); + } + + /** + * @param bool $reachable + * @return NodeType + */ + public function setReachable(bool $reachable): NodeType + { + $this->reachable = $reachable; + return $this; + } + + /** + * @return boolean + */ + public function isHidingNodes(): bool + { + return $this->hidingNodes; + } + + /** + * @param boolean $hidingNodes + * + * @return $this + */ + public function setHidingNodes(bool $hidingNodes): NodeType + { + $this->hidingNodes = $hidingNodes; + return $this; + } + + /** + * @return bool + */ + public function isHidingNonReachableNodes(): bool + { + return $this->hidingNonReachableNodes; + } + + /** + * @param bool $hidingNonReachableNodes + * + * @return NodeType + */ + public function setHidingNonReachableNodes(bool $hidingNonReachableNodes): NodeType + { + $this->hidingNonReachableNodes = $hidingNonReachableNodes; + return $this; + } + + /** + * Gets the value of color. + * + * @return string|null + */ + public function getColor(): ?string + { + return $this->color; + } + + /** + * Sets the value of color. + * + * @param string|null $color + * + * @return $this + */ + public function setColor(?string $color): NodeType + { + $this->color = $color; + + return $this; + } + + /** + * @return int + */ + public function getDefaultTtl(): int + { + return $this->defaultTtl; + } + + /** + * @param int $defaultTtl + * + * @return NodeType + */ + public function setDefaultTtl(int $defaultTtl): NodeType + { + $this->defaultTtl = $defaultTtl; + + return $this; + } + + /** + * @param string $name + * + * @return NodeTypeField|null + */ + public function getFieldByName(string $name): ?NodeTypeField + { + $fieldCriteria = Criteria::create(); + $fieldCriteria->andWhere(Criteria::expr()->eq('name', $name)); + $fieldCriteria->setMaxResults(1); + $field = $this->getFields()->matching($fieldCriteria)->first(); + return $field ?: null; + } + + /** + * @return Collection + */ + public function getFields(): Collection + { + return $this->fields; + } + + /** + * @param Collection $fields + * + * @return NodeType + */ + public function setFields(Collection $fields): NodeType + { + $this->fields = $fields; + foreach ($this->fields as $field) { + $field->setNodeType($this); + } + + return $this; + } + + /** + * Get every node-type fields names in + * a simple array. + * + * @return array + */ + #[SymfonySerializer\Ignore] + public function getFieldsNames(): array + { + return array_map(function (NodeTypeField $field) { + return $field->getName(); + }, $this->getFields()->toArray()); + } + + /** + * @return string + */ + public function getName(): string + { + return $this->name; + } + + /** + * @param string|null $name + * @return $this + */ + public function setName(?string $name): NodeType + { + $this->name = StringHandler::classify($name ?? ''); + return $this; + } + + /** + * @param NodeTypeField $field + * + * @return NodeType + */ + public function addField(NodeTypeField $field): NodeType + { + if (!$this->getFields()->contains($field)) { + $this->getFields()->add($field); + $field->setNodeType($this); + } + + return $this; + } + + /** + * @param NodeTypeField $field + * + * @return NodeType + */ + public function removeField(NodeTypeField $field): NodeType + { + if ($this->getFields()->contains($field)) { + $this->getFields()->removeElement($field); + } + + return $this; + } + + /** + * @return class-string + */ + #[SymfonySerializer\Ignore] + public function getSourceEntityFullQualifiedClassName(): string + { + return static::getGeneratedEntitiesNamespace() . '\\' . $this->getSourceEntityClassName(); + } + + /** + * @return string + */ + #[SymfonySerializer\Ignore] + public static function getGeneratedEntitiesNamespace(): string + { + return 'App\\GeneratedEntity'; + } + + /** + * Get node-source entity class name without its namespace. + * + * @return string + */ + #[SymfonySerializer\Ignore] + public function getSourceEntityClassName(): string + { + return 'NS' . ucwords($this->getName()); + } + + /** + * Get node-source entity database table name. + * + * @return string + */ + #[SymfonySerializer\Ignore] + public function getSourceEntityTableName(): string + { + return 'ns_' . strtolower($this->getName()); + } + + /** + * @return string + */ + public function __toString(): string + { + return (string) $this->getId(); + } + + /** + * Get every searchable node-type fields as a Doctrine ArrayCollection. + * + * @return Collection + */ + #[SymfonySerializer\Ignore] + public function getSearchableFields(): Collection + { + return $this->getFields()->filter(function (NodeTypeField $field) { + return $field->isSearchable(); + }); + } + + /** + * @return bool + */ + public function isSearchable(): bool + { + return $this->searchable; + } + + /** + * @param bool $searchable + * @return NodeType + */ + public function setSearchable(bool $searchable): NodeType + { + $this->searchable = $searchable; + return $this; + } +} diff --git a/lib/RoadizCoreBundle/src/Entity/NodeTypeField.php b/lib/RoadizCoreBundle/src/Entity/NodeTypeField.php new file mode 100644 index 00000000..dedfcb7c --- /dev/null +++ b/lib/RoadizCoreBundle/src/Entity/NodeTypeField.php @@ -0,0 +1,418 @@ + false]), + Serializer\Groups(["node_type"]), + SymfonySerializer\Groups(["node_type"]), + Serializer\Type("bool") + ] + private bool $universal = false; + + /** + * Exclude current field from full-text search engines. + */ + #[ + ORM\Column(name: "exclude_from_search", type: "boolean", nullable: false, options: ["default" => false]), + Serializer\Groups(["node_type"]), + SymfonySerializer\Groups(["node_type"]), + Serializer\Type("bool") + ] + private bool $excludeFromSearch = false; + + #[ + ORM\ManyToOne(targetEntity: NodeType::class, inversedBy: "fields"), + ORM\JoinColumn(name: "node_type_id", onDelete: "CASCADE"), + Serializer\Exclude(), + SymfonySerializer\Ignore + ] + private ?NodeTypeInterface $nodeType = null; + + #[ + Serializer\Groups(["node_type"]), + SymfonySerializer\Groups(["node_type"]), + Serializer\Type("string"), + ORM\Column(name: "serialization_exclusion_expression", type: "text", nullable: true) + ] + private ?string $serializationExclusionExpression = null; + + #[ + Serializer\Groups(["node_type"]), + SymfonySerializer\Groups(["node_type"]), + Serializer\Type("array"), + ORM\Column(name: "serialization_groups", type: "json", nullable: true) + ] + private ?array $serializationGroups = null; + + #[ + Serializer\Groups(["node_type"]), + SymfonySerializer\Groups(["node_type"]), + Serializer\Type("int"), + ORM\Column(name: "serialization_max_depth", type: "integer", nullable: true) + ] + private ?int $serializationMaxDepth = null; + + #[ + Serializer\Groups(["node_type"]), + SymfonySerializer\Groups(["node_type"]), + Serializer\Type("bool"), + ORM\Column(name: "excluded_from_serialization", type: "boolean", nullable: false, options: ["default" => false]) + ] + private bool $excludedFromSerialization = false; + + #[ + ORM\Column(name: "min_length", type: "integer", nullable: true), + Serializer\Groups(["node_type"]), + SymfonySerializer\Groups(["node_type"]), + Serializer\Type("int") + ] + private ?int $minLength = null; + + #[ + ORM\Column(name: "max_length", type: "integer", nullable: true), + Serializer\Groups(["node_type"]), + SymfonySerializer\Groups(["node_type"]), + Serializer\Type("int") + ] + private ?int $maxLength = null; + + #[ + ORM\Column(type: "boolean", nullable: false, options: ["default" => false]), + Serializer\Groups(["node_type"]), + SymfonySerializer\Groups(["node_type"]), + Serializer\Type("bool") + ] + private bool $indexed = false; + + #[ + ORM\Column(type: "boolean", nullable: false, options: ["default" => true]), + Serializer\Groups(["node_type"]), + SymfonySerializer\Groups(["node_type"]), + Serializer\Type("bool") + ] + private bool $visible = true; + + #[ + Serializer\VirtualProperty(), + Serializer\Type("string"), + Serializer\Groups(["node_type"]), + SymfonySerializer\Groups(["node_type"]) + ] + public function getNodeTypeName(): string + { + return $this->getNodeType() ? $this->getNodeType()->getName() : ''; + } + + /** + * @return NodeTypeInterface|null + */ + public function getNodeType(): ?NodeTypeInterface + { + return $this->nodeType; + } + + /** + * @param NodeTypeInterface|null $nodeType + * + * @return $this + */ + public function setNodeType(?NodeTypeInterface $nodeType) + { + $this->nodeType = $nodeType; + + return $this; + } + + /** + * @return int|null + */ + public function getMinLength(): ?int + { + return $this->minLength; + } + + /** + * @param int|null $minLength + * + * @return $this + */ + public function setMinLength(?int $minLength) + { + $this->minLength = $minLength; + + return $this; + } + + /** + * @return int|null + */ + public function getMaxLength(): ?int + { + return $this->maxLength; + } + + /** + * @param int|null $maxLength + * + * @return $this + */ + public function setMaxLength(?int $maxLength) + { + $this->maxLength = $maxLength; + + return $this; + } + + /** + * Tell if current field can be searched and indexed in a Search engine server. + * + * @return bool + */ + public function isSearchable(): bool + { + return !$this->excludeFromSearch && in_array($this->getType(), static::$searchableTypes); + } + + /** + * @return string + */ + #[SymfonySerializer\Ignore] + public function getOneLineSummary() + { + return $this->getId() . " — " . $this->getLabel() . ' [' . $this->getName() . ']' . + ' - ' . $this->getTypeName() . + ($this->isIndexed() ? ' - indexed' : '') . + (!$this->isVisible() ? ' - hidden' : '') . PHP_EOL; + } + + /** + * @return boolean $isIndexed + */ + public function isIndexed(): bool + { + // JSON types cannot be indexed + return $this->indexed && $this->getDoctrineType() !== 'json'; + } + + /** + * @param bool $indexed + * @return $this + */ + public function setIndexed(bool $indexed) + { + $this->indexed = $indexed; + return $this; + } + + /** + * @return bool + */ + public function isVisible(): bool + { + return $this->visible; + } + + /** + * @param bool $visible + * @return $this + */ + public function setVisible(bool $visible) + { + $this->visible = $visible; + + return $this; + } + + /** + * @return bool + */ + public function isUniversal(): bool + { + return $this->universal; + } + + /** + * @see Same as isUniversal + * @return bool + */ + public function getUniversal(): bool + { + return $this->universal; + } + + /** + * @param bool $universal + * @return NodeTypeField + */ + public function setUniversal(bool $universal) + { + $this->universal = $universal; + return $this; + } + + /** + * @return bool + */ + public function isExcludedFromSearch(): bool + { + return $this->getExcludeFromSearch(); + } + + /** + * @return bool + */ + public function getExcludeFromSearch(): bool + { + return $this->excludeFromSearch; + } + + /** + * @return bool + */ + public function isExcludeFromSearch(): bool + { + return $this->getExcludeFromSearch(); + } + + /** + * @param bool $excludeFromSearch + * + * @return NodeTypeField + */ + public function setExcludeFromSearch(bool $excludeFromSearch) + { + $this->excludeFromSearch = $excludeFromSearch; + + return $this; + } + + /** + * @return string|null + */ + public function getSerializationExclusionExpression(): ?string + { + return $this->serializationExclusionExpression; + } + + /** + * @param string|null $serializationExclusionExpression + * @return NodeTypeField + */ + public function setSerializationExclusionExpression(?string $serializationExclusionExpression): NodeTypeField + { + $this->serializationExclusionExpression = $serializationExclusionExpression; + return $this; + } + + /** + * @return array + */ + public function getSerializationGroups(): array + { + return array_filter($this->serializationGroups ?? []); + } + + /** + * @param array|null $serializationGroups + * @return NodeTypeField + */ + public function setSerializationGroups(?array $serializationGroups): NodeTypeField + { + $this->serializationGroups = $serializationGroups; + if (null !== $this->serializationGroups) { + $this->serializationGroups = array_filter($this->serializationGroups); + } + if (empty($this->serializationGroups)) { + $this->serializationGroups = null; + } + return $this; + } + + /** + * @return int|null + */ + public function getSerializationMaxDepth(): ?int + { + return $this->serializationMaxDepth; + } + + /** + * @param int|null $serializationMaxDepth + * @return NodeTypeField + */ + public function setSerializationMaxDepth(?int $serializationMaxDepth): NodeTypeField + { + $this->serializationMaxDepth = $serializationMaxDepth; + return $this; + } + + /** + * @return bool + */ + public function isExcludedFromSerialization(): bool + { + return $this->excludedFromSerialization; + } + + /** + * @param bool $excludedFromSerialization + * @return NodeTypeField + */ + public function setExcludedFromSerialization(bool $excludedFromSerialization): NodeTypeField + { + $this->excludedFromSerialization = $excludedFromSerialization; + return $this; + } +} diff --git a/lib/RoadizCoreBundle/src/Entity/NodesCustomForms.php b/lib/RoadizCoreBundle/src/Entity/NodesCustomForms.php new file mode 100644 index 00000000..97335583 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Entity/NodesCustomForms.php @@ -0,0 +1,141 @@ +node = $node; + $this->customForm = $customForm; + $this->field = $field; + } + + public function __clone() + { + if ($this->id) { + $this->id = null; + $this->node = null; + } + } + + /** + * Gets the value of node. + * + * @return Node|null + */ + public function getNode(): ?Node + { + return $this->node; + } + + /** + * Sets the value of node. + * + * @param Node $node the node + * + * @return self + */ + public function setNode(Node $node): NodesCustomForms + { + $this->node = $node; + return $this; + } + + /** + * Gets the value of customForm. + * + * @return CustomForm + */ + public function getCustomForm(): CustomForm + { + return $this->customForm; + } + + /** + * Sets the value of customForm. + * + * @param CustomForm $customForm the custom form + * + * @return self + */ + public function setCustomForm(CustomForm $customForm): NodesCustomForms + { + $this->customForm = $customForm; + + return $this; + } + + /** + * Gets the value of field. + * + * @return NodeTypeField + */ + public function getField(): NodeTypeField + { + return $this->field; + } + + /** + * Sets the value of field. + * + * @param NodeTypeField $field the field + * + * @return self + */ + public function setField(NodeTypeField $field): NodesCustomForms + { + $this->field = $field; + + return $this; + } +} diff --git a/lib/RoadizCoreBundle/src/Entity/NodesSources.php b/lib/RoadizCoreBundle/src/Entity/NodesSources.php new file mode 100644 index 00000000..cf5fab3f --- /dev/null +++ b/lib/RoadizCoreBundle/src/Entity/NodesSources.php @@ -0,0 +1,672 @@ + + */ + #[ORM\OneToMany(mappedBy: 'nodeSource', targetEntity: Log::class)] + #[ORM\OrderBy(['datetime' => 'DESC'])] + #[SymfonySerializer\Ignore] + #[Serializer\Exclude] + protected Collection $logs; + + /** + * @var Collection + */ + #[ORM\OneToMany(mappedBy: 'redirectNodeSource', targetEntity: Redirection::class)] + #[SymfonySerializer\Ignore] + #[Serializer\Exclude] + protected Collection $redirections; + + #[ApiFilter(BaseFilter\SearchFilter::class, strategy: "partial")] + #[ORM\Column(name: 'title', type: 'string', unique: false, nullable: true)] + #[SymfonySerializer\Groups(['nodes_sources', 'nodes_sources_base', 'log_sources'])] + #[Serializer\Groups(['nodes_sources', 'nodes_sources_base', 'log_sources'])] + #[Gedmo\Versioned] + protected ?string $title = null; + + #[ApiFilter(BaseFilter\DateFilter::class)] + #[ApiFilter(BaseFilter\OrderFilter::class)] + #[ApiFilter(RoadizFilter\ArchiveFilter::class)] + #[ORM\Column(name: 'published_at', type: 'datetime', unique: false, nullable: true)] + #[SymfonySerializer\Groups(['nodes_sources', 'nodes_sources_base'])] + #[Serializer\Groups(['nodes_sources', 'nodes_sources_base'])] + #[Gedmo\Versioned] + protected ?\DateTime $publishedAt = null; + + #[ApiFilter(BaseFilter\SearchFilter::class, strategy: "partial")] + #[ORM\Column(name: 'meta_title', type: 'string', unique: false)] + #[SymfonySerializer\Groups(['nodes_sources'])] + #[Serializer\Groups(['nodes_sources'])] + #[Gedmo\Versioned] + protected string $metaTitle = ''; + + #[ORM\Column(name: 'meta_keywords', type: 'text')] + #[SymfonySerializer\Groups(['nodes_sources'])] + #[Serializer\Groups(['nodes_sources'])] + #[Gedmo\Versioned] + protected string $metaKeywords = ''; + + #[ApiFilter(BaseFilter\SearchFilter::class, strategy: "partial")] + #[ORM\Column(name: 'meta_description', type: 'text')] + #[SymfonySerializer\Groups(['nodes_sources'])] + #[Serializer\Groups(['nodes_sources'])] + #[Gedmo\Versioned] + protected string $metaDescription = ''; + + #[ApiFilter(BaseFilter\BooleanFilter::class)] + #[ORM\Column(name: 'no_index', type: 'boolean', options: ['default' => false])] + #[SymfonySerializer\Groups(['nodes_sources'])] + #[Serializer\Groups(['nodes_sources'])] + #[Gedmo\Versioned] + protected bool $noIndex = false; + + #[ApiFilter(BaseFilter\SearchFilter::class, properties: [ + "node.id" => "exact", + "node.nodeName" => "exact", + "node.parent" => "exact", + "node.parent.nodeName" => "exact", + "node.nodesTags.tag" => "exact", + "node.nodesTags.tag.tagName" => "exact", + "node.nodeType" => "exact", + "node.nodeType.name" => "exact" + ])] + #[ApiFilter(BaseFilter\OrderFilter::class, properties: [ + "node.position", + "node.createdAt", + "node.updatedAt" + ])] + #[ApiFilter(BaseFilter\DateFilter::class, properties: [ + "node.createdAt", + "node.updatedAt" + ])] + #[ApiFilter(BaseFilter\BooleanFilter::class, properties: [ + "node.visible", + "node.home", + "node.nodeType.reachable", + "node.nodeType.publishable" + ])] + #[ApiFilter(RoadizFilter\NotFilter::class, properties: [ + "node.nodeType.name", + "node.id", + "node.nodesTags.tag.tagName", + ])] + # Use IntersectionFilter after SearchFilter! + #[ApiFilter(RoadizFilter\IntersectionFilter::class, properties: [ + "node.nodesTags.tag", + "node.nodesTags.tag.tagName", + ])] + #[ORM\ManyToOne(targetEntity: Node::class, cascade: ['persist'], fetch: 'EAGER', inversedBy: 'nodeSources')] + #[ORM\JoinColumn(name: 'node_id', referencedColumnName: 'id', onDelete: 'CASCADE')] + #[SymfonySerializer\Groups(['nodes_sources', 'nodes_sources_base', 'log_sources'])] + #[Serializer\Groups(['nodes_sources', 'nodes_sources_base', 'log_sources'])] + private ?Node $node = null; + + #[ApiFilter(BaseFilter\SearchFilter::class, properties: [ + "translation.id" => "exact", + "translation.locale" => "exact", + ])] + #[ORM\ManyToOne(targetEntity: Translation::class, inversedBy: 'nodeSources')] + #[ORM\JoinColumn(name: 'translation_id', referencedColumnName: 'id', onDelete: 'CASCADE')] + #[SymfonySerializer\Groups(['translation_base'])] + #[Serializer\Groups(['translation_base'])] + private ?TranslationInterface $translation = null; + + /** + * @var Collection + */ + #[ORM\OneToMany( + mappedBy: 'nodeSource', + targetEntity: UrlAlias::class, + cascade: ['all'] + )] + #[SymfonySerializer\Ignore] + private Collection $urlAliases; + + /** + * @var Collection + */ + #[ORM\OneToMany( + mappedBy: 'nodeSource', + targetEntity: NodesSourcesDocuments::class, + cascade: ['persist'], + fetch: 'LAZY', + orphanRemoval: true + )] + #[SymfonySerializer\Ignore] + #[Serializer\Exclude] + private Collection $documentsByFields; + + /** + * Create a new NodeSource with its Node and Translation. + * + * @param Node $node + * @param TranslationInterface $translation + */ + public function __construct(Node $node, TranslationInterface $translation) + { + $this->setNode($node); + $this->translation = $translation; + $this->urlAliases = new ArrayCollection(); + $this->documentsByFields = new ArrayCollection(); + $this->logs = new ArrayCollection(); + $this->redirections = new ArrayCollection(); + } + + /** + * @inheritDoc + * @Serializer\Exclude + */ + public function injectObjectManager(ObjectManager $objectManager): void + { + $this->objectManager = $objectManager; + } + + #[ORM\PreUpdate] + public function preUpdate(): void + { + $this->getNode()?->setUpdatedAt(new \DateTime("now")); + } + + /** + * @return Node|null + */ + public function getNode(): ?Node + { + return $this->node; + } + + /** + * @param Node|null $node + * + * @return $this + */ + public function setNode(Node $node = null): NodesSources + { + $this->node = $node; + if (null !== $node) { + $node->addNodeSources($this); + } + + return $this; + } + + /** + * @param UrlAlias $urlAlias + * @return $this + */ + public function addUrlAlias(UrlAlias $urlAlias): NodesSources + { + if (!$this->urlAliases->contains($urlAlias)) { + $this->urlAliases->add($urlAlias); + $urlAlias->setNodeSource($this); + } + + return $this; + } + + public function clearDocumentsByFields(NodeTypeField $nodeTypeField): NodesSources + { + $toRemoveCollection = $this->getDocumentsByFields()->filter( + function (NodesSourcesDocuments $element) use ($nodeTypeField) { + return $element->getField()->getId() === $nodeTypeField->getId(); + } + ); + /** @var NodesSourcesDocuments $toRemove */ + foreach ($toRemoveCollection as $toRemove) { + $this->getDocumentsByFields()->removeElement($toRemove); + $toRemove->setNodeSource(null); + } + + return $this; + } + + /** + * @return Collection + */ + public function getDocumentsByFields(): Collection + { + return $this->documentsByFields; + } + + /** + * @param ArrayCollection $documentsByFields + * + * @return NodesSources + */ + public function setDocumentsByFields(ArrayCollection $documentsByFields): NodesSources + { + foreach ($this->documentsByFields as $documentsByField) { + $documentsByField->setNodeSource(null); + } + $this->documentsByFields->clear(); + foreach ($documentsByFields as $documentsByField) { + if (!$this->hasNodesSourcesDocuments($documentsByField)) { + $this->addDocumentsByFields($documentsByField); + } + } + + return $this; + } + + /** + * @param NodesSourcesDocuments $nodesSourcesDocuments + * @return bool + */ + #[SymfonySerializer\Ignore] + public function hasNodesSourcesDocuments(NodesSourcesDocuments $nodesSourcesDocuments): bool + { + return $this->getDocumentsByFields()->exists( + function ($key, NodesSourcesDocuments $element) use ($nodesSourcesDocuments) { + return $nodesSourcesDocuments->getDocument()->getId() !== null && + $element->getDocument()->getId() === $nodesSourcesDocuments->getDocument()->getId() && + $element->getField()->getId() === $nodesSourcesDocuments->getField()->getId(); + } + ); + } + + /** + * Used by any NSClass to add directly new documents to source. + * + * @param NodesSourcesDocuments $nodesSourcesDocuments + * + * @return $this + */ + public function addDocumentsByFields(NodesSourcesDocuments $nodesSourcesDocuments): NodesSources + { + if (!$this->getDocumentsByFields()->contains($nodesSourcesDocuments)) { + $this->getDocumentsByFields()->add($nodesSourcesDocuments); + $nodesSourcesDocuments->setNodeSource($this); + } + return $this; + } + + /** + * @param NodeTypeField $field + * + * @return Document[] + */ + public function getDocumentsByFieldsWithField(NodeTypeField $field): array + { + $criteria = Criteria::create(); + $criteria->orderBy(['position' => 'ASC']); + return $this->getDocumentsByFields() + ->matching($criteria) + ->filter(function ($element) use ($field) { + if ($element instanceof NodesSourcesDocuments) { + return $element->getField() === $field; + } + return false; + }) + ->map(function (NodesSourcesDocuments $nodesSourcesDocuments) { + return $nodesSourcesDocuments->getDocument(); + }) + ->toArray() + ; + } + + /** + * @param string $fieldName + * @return Document[] + */ + public function getDocumentsByFieldsWithName(string $fieldName): array + { + $criteria = Criteria::create(); + $criteria->orderBy(['position' => 'ASC']); + return $this->getDocumentsByFields() + ->matching($criteria) + ->filter(function ($element) use ($fieldName) { + if ($element instanceof NodesSourcesDocuments) { + return $element->getField()->getName() === $fieldName; + } + return false; + }) + ->map(function (NodesSourcesDocuments $nodesSourcesDocuments) { + return $nodesSourcesDocuments->getDocument(); + }) + ->toArray() + ; + } + + /** + * Logs related to this node-source. + * + * @return Collection + */ + public function getLogs(): Collection + { + return $this->logs; + } + + /** + * @param Collection $logs + * @return $this + */ + public function setLogs(Collection $logs): NodesSources + { + $this->logs = $logs; + + return $this; + } + + /** + * @return Collection + */ + public function getRedirections(): Collection + { + return $this->redirections; + } + + /** + * @param Collection $redirections + * @return NodesSources + */ + public function setRedirections(Collection $redirections): NodesSources + { + $this->redirections = $redirections; + return $this; + } + + /** + * @return \DateTime|null + */ + public function getPublishedAt(): ?\DateTime + { + return $this->publishedAt; + } + + /** + * @param \DateTime|null $publishedAt + * @return NodesSources + */ + public function setPublishedAt(\DateTime $publishedAt = null): NodesSources + { + $this->publishedAt = $publishedAt; + return $this; + } + + /** + * @return string + */ + public function getMetaTitle(): string + { + return $this->metaTitle; + } + + /** + * @param string|null $metaTitle + * + * @return $this + */ + public function setMetaTitle(?string $metaTitle): NodesSources + { + $this->metaTitle = null !== $metaTitle ? trim($metaTitle) : ''; + + return $this; + } + + /** + * @return string + */ + public function getMetaKeywords(): string + { + return $this->metaKeywords; + } + + /** + * @param string|null $metaKeywords + * + * @return $this + */ + public function setMetaKeywords(?string $metaKeywords): NodesSources + { + $this->metaKeywords = null !== $metaKeywords ? trim($metaKeywords) : ''; + + return $this; + } + + /** + * @return string + */ + public function getMetaDescription(): string + { + return $this->metaDescription; + } + + /** + * @param string|null $metaDescription + * + * @return $this + */ + public function setMetaDescription(?string $metaDescription): NodesSources + { + $this->metaDescription = null !== $metaDescription ? trim($metaDescription) : ''; + + return $this; + } + + /** + * @return bool + */ + public function isNoIndex(): bool + { + return $this->noIndex; + } + + /** + * @param bool $noIndex + * @return NodesSources + */ + public function setNoIndex(bool $noIndex): NodesSources + { + $this->noIndex = $noIndex; + return $this; + } + + /** + * @return string + * @Serializer\VirtualProperty + * @Serializer\SerializedName("slug") + * @Serializer\Groups({"nodes_sources", "nodes_sources_base"}) + */ + #[SymfonySerializer\SerializedName('slug')] + #[SymfonySerializer\Groups(['nodes_sources', 'nodes_sources_base'])] + public function getIdentifier(): string + { + $urlAlias = $this->getUrlAliases()->first(); + if (false !== $urlAlias && $urlAlias->getAlias() !== '') { + return $urlAlias->getAlias(); + } + + return $this->getNode()->getNodeName(); + } + + /** + * @return Collection + */ + public function getUrlAliases(): Collection + { + return $this->urlAliases; + } + + /** + * Get parent node’ source based on the same translation. + * + * @return NodesSources|null + * @Serializer\Exclude + */ + #[SymfonySerializer\Ignore] + public function getParent(): ?NodesSources + { + /** @var Node|null $parent */ + $parent = $this->getNode()->getParent(); + if (null !== $parent) { + /** @var NodesSources|false $nodeSources */ + $nodeSources = $parent->getNodeSourcesByTranslation($this->translation)->first(); + return $nodeSources ?: null; + } else { + return null; + } + } + + /** + * @return string + */ + public function __toString(): string + { + return (string) $this->getId(); + } + + /** + * @return string|null + */ + public function getTitle(): ?string + { + return $this->title; + } + + /** + * @param string|null $title + * @return $this + */ + public function setTitle(?string $title): NodesSources + { + $this->title = null !== $title ? trim($title) : null; + return $this; + } + + /** + * @return TranslationInterface + */ + public function getTranslation(): TranslationInterface + { + if (null === $this->translation) { + throw new RuntimeException('Node source translation cannot be null.'); + } + return $this->translation; + } + + /** + * @param TranslationInterface $translation + * + * @return $this + */ + public function setTranslation(TranslationInterface $translation): NodesSources + { + $this->translation = $translation; + return $this; + } + + /** + * @return string + * @Serializer\VirtualProperty + * @Serializer\Groups({"nodes_sources", "nodes_sources_default"}) + * @Serializer\SerializedName("@type") + */ + #[SymfonySerializer\Groups(['nodes_sources', 'nodes_sources_default'])] + #[SymfonySerializer\SerializedName('@type')] + public function getNodeTypeName(): string + { + return 'NodesSources'; + } + + /** + * Overridden in NS classes. + * + * @return bool + */ + public function isPublishable(): bool + { + return $this->getNode()->getNodeType()->isPublishable(); + } + + /** + * Overridden in NS classes. + * + * @return bool + */ + public function isReachable(): bool + { + return $this->getNode()->getNodeType()->isReachable(); + } + + /** + * After clone method. + * + * Be careful not to persist nor flush current entity after + * calling clone as it empties its relations. + */ + public function __clone() + { + if ($this->id) { + $this->id = null; + $documentsByFields = $this->getDocumentsByFields(); + $this->documentsByFields = new ArrayCollection(); + foreach ($documentsByFields as $documentsByField) { + $cloneDocumentsByField = clone $documentsByField; + $this->documentsByFields->add($cloneDocumentsByField); + $cloneDocumentsByField->setNodeSource($this); + } + // Clear url-aliases before cloning. + $this->urlAliases->clear(); + // Clear logs before cloning. + $this->logs->clear(); + } + } +} diff --git a/lib/RoadizCoreBundle/src/Entity/NodesSourcesDocuments.php b/lib/RoadizCoreBundle/src/Entity/NodesSourcesDocuments.php new file mode 100644 index 00000000..4e3fadc3 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Entity/NodesSourcesDocuments.php @@ -0,0 +1,152 @@ +nodeSource = $nodeSource; + $this->document = $document; + $this->field = $field; + } + + public function __clone() + { + if ($this->id) { + $this->id = null; + $this->nodeSource = null; + } + } + + /** + * Gets the value of nodeSource. + * + * @return NodesSources|null + */ + public function getNodeSource(): ?NodesSources + { + return $this->nodeSource; + } + + /** + * Sets the value of nodeSource. + * + * @param NodesSources|null $nodeSource the node source + * + * @return self + */ + public function setNodeSource(?NodesSources $nodeSource): NodesSourcesDocuments + { + $this->nodeSource = $nodeSource; + + return $this; + } + + /** + * Gets the value of document. + * + * @return Document|null + */ + public function getDocument(): ?Document + { + return $this->document; + } + + /** + * Sets the value of document. + * + * @param Document|null $document the document + * + * @return self + */ + public function setDocument(?Document $document): NodesSourcesDocuments + { + $this->document = $document; + + return $this; + } + + /** + * Gets the value of field. + * + * @return NodeTypeField|null + */ + public function getField(): ?NodeTypeField + { + return $this->field; + } + + /** + * Sets the value of field. + * + * @param NodeTypeField|null $field the field + * + * @return self + */ + public function setField(?NodeTypeField $field): NodesSourcesDocuments + { + $this->field = $field; + + return $this; + } +} diff --git a/lib/RoadizCoreBundle/src/Entity/NodesTags.php b/lib/RoadizCoreBundle/src/Entity/NodesTags.php new file mode 100644 index 00000000..4a525c9b --- /dev/null +++ b/lib/RoadizCoreBundle/src/Entity/NodesTags.php @@ -0,0 +1,100 @@ + 1]), + SymfonySerializer\Ignore, + Serializer\Exclude, + ] + protected float $position = 0.0; + + /** + * @return Node + */ + public function getNode(): Node + { + return $this->node; + } + + /** + * @param Node $node + * @return NodesTags + */ + public function setNode(Node $node): NodesTags + { + $this->node = $node; + return $this; + } + + /** + * @return Tag + */ + public function getTag(): Tag + { + return $this->tag; + } + + /** + * @param Tag $tag + * @return NodesTags + */ + public function setTag(Tag $tag): NodesTags + { + $this->tag = $tag; + return $this; + } +} diff --git a/lib/RoadizCoreBundle/src/Entity/NodesToNodes.php b/lib/RoadizCoreBundle/src/Entity/NodesToNodes.php new file mode 100644 index 00000000..ebcb4b6c --- /dev/null +++ b/lib/RoadizCoreBundle/src/Entity/NodesToNodes.php @@ -0,0 +1,140 @@ +nodeA = $nodeA; + $this->nodeB = $nodeB; + $this->field = $field; + } + + public function __clone() + { + if ($this->id) { + $this->id = null; + $this->nodeA = null; + } + } + + /** + * Gets the value of nodeA. + * + * @return Node|null + */ + public function getNodeA(): ?Node + { + return $this->nodeA; + } + + /** + * Sets the value of nodeA. + * + * @param Node|null $nodeA the node + * + * @return self + */ + public function setNodeA(?Node $nodeA): NodesToNodes + { + $this->nodeA = $nodeA; + + return $this; + } + + /** + * Gets the value of nodeB. + * + * @return Node|null + */ + public function getNodeB(): ?Node + { + return $this->nodeB; + } + + /** + * Sets the value of nodeB. + * + * @param Node|null $nodeB the node + * + * @return self + */ + public function setNodeB(?Node $nodeB): NodesToNodes + { + $this->nodeB = $nodeB; + + return $this; + } + + /** + * Gets the value of field. + * + * @return NodeTypeField|null + */ + public function getField(): ?NodeTypeField + { + return $this->field; + } + + /** + * Sets the value of field. + * + * @param NodeTypeField|null $field the field + * + * @return self + */ + public function setField(?NodeTypeField $field): NodesToNodes + { + $this->field = $field; + + return $this; + } +} diff --git a/lib/RoadizCoreBundle/src/Entity/Realm.php b/lib/RoadizCoreBundle/src/Entity/Realm.php new file mode 100644 index 00000000..92000fc9 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Entity/Realm.php @@ -0,0 +1,284 @@ + "exact", + "behaviour" => "exact", + "name" => "exact" + ]) +] +class Realm extends AbstractEntity implements RealmInterface +{ + #[ORM\Column(name: 'type', type: 'string', length: 30)] + #[SymfonySerializer\Groups(['get', 'realm'])] + #[Serializer\Groups(['get', 'realm'])] + private string $type = RealmInterface::TYPE_PLAIN_PASSWORD; + + #[ORM\Column(name: 'behaviour', type: 'string', length: 30, nullable: false, options: ['default' => 'none'])] + #[SymfonySerializer\Groups(['get', 'realm', 'web_response'])] + #[Serializer\Groups(['get', 'realm', 'web_response'])] + private string $behaviour = RealmInterface::BEHAVIOUR_NONE; + + #[ORM\Column(name: 'name', unique: true)] + #[SymfonySerializer\Groups(['get', 'realm', 'web_response'])] + #[Serializer\Groups(['get', 'realm', 'web_response'])] + #[Assert\NotBlank] + #[Assert\NotNull] + #[Assert\Length(max: 250)] + #[Assert\Regex('#^[\w\s]+$#u')] + private string $name = ''; + + /** + * @var string|null + * @Serializer\Exclude() + */ + #[ORM\Column(name: 'plain_password', unique: false, type: 'string', length: 255, nullable: true)] + #[SymfonySerializer\Ignore] + private ?string $plainPassword = null; + + #[ORM\ManyToOne(targetEntity: Role::class)] + #[ORM\JoinColumn(name: 'role_id', referencedColumnName: 'id', onDelete: 'SET NULL')] + #[SymfonySerializer\Ignore] + #[Serializer\Exclude] + private ?Role $roleEntity = null; + + #[ORM\Column(name: 'serialization_group', type: 'string', length: 200, nullable: true)] + #[SymfonySerializer\Ignore] + #[Serializer\Exclude] + private ?string $serializationGroup = null; + + /** + * @var Collection + */ + #[ORM\JoinTable(name: 'realms_users')] + #[ORM\ManyToMany(targetEntity: User::class)] + #[SymfonySerializer\Ignore] + #[Serializer\Exclude] + private Collection $users; + + /** + * @var Collection + */ + #[ORM\OneToMany(mappedBy: 'realm', targetEntity: RealmNode::class)] + #[SymfonySerializer\Ignore] + #[Serializer\Exclude] + private Collection $realmNodes; + + public function __construct() + { + $this->users = new ArrayCollection(); + $this->realmNodes = new ArrayCollection(); + } + + /** + * @return string|null + */ + public function getRole(): ?string + { + if (null === $this->roleEntity) { + return null; + } + return $this->roleEntity->getRole(); + } + + /** + * @return Role|null + */ + public function getRoleEntity(): ?Role + { + return $this->roleEntity; + } + + /** + * @param Role|null $roleEntity + * @return Realm + */ + public function setRoleEntity(?Role $roleEntity): Realm + { + $this->roleEntity = $roleEntity; + return $this; + } + + /** + * @return string|null + */ + public function getSerializationGroup(): ?string + { + return $this->serializationGroup; + } + + /** + * @param string|null $serializationGroup + * @return Realm + */ + public function setSerializationGroup(?string $serializationGroup): Realm + { + $this->serializationGroup = null !== $serializationGroup ? + (new AsciiSlugger())->slug($serializationGroup, '_')->lower()->toString() : + (new AsciiSlugger())->slug($this->getName(), '_')->lower()->toString(); + return $this; + } + + /** + * @return string + */ + public function getName(): string + { + return $this->name ?? ''; + } + + /** + * @param string $name + * @return Realm + */ + public function setName(string $name): Realm + { + $this->name = $name; + if (null === $this->serializationGroup) { + $this->serializationGroup = (new AsciiSlugger())->slug($this->name, '_')->lower()->toString(); + } + return $this; + } + + /** + * @return ArrayCollection|Collection + */ + public function getRealmNodes(): Collection + { + return $this->realmNodes; + } + + /** + * @param ArrayCollection|Collection $realmNodes + * @return Realm + */ + public function setRealmNodes(Collection $realmNodes) + { + $this->realmNodes = $realmNodes; + return $this; + } + + /** + * @return Collection|ArrayCollection + */ + public function getUsers(): Collection + { + return $this->users; + } + + /** + * @param Collection|ArrayCollection $users + * @return Realm + */ + public function setUsers(Collection $users) + { + $this->users = $users; + return $this; + } + + /** + * @return string + */ + public function getPlainPassword(): ?string + { + return $this->plainPassword; + } + + /** + * @param string $plainPassword + * @return Realm + */ + public function setPlainPassword(?string $plainPassword): Realm + { + $this->plainPassword = $plainPassword; + return $this; + } + + /** + * @return string + */ + public function getBehaviour(): string + { + return $this->behaviour; + } + + /** + * @param string $behaviour + * @return Realm + */ + public function setBehaviour(string $behaviour): Realm + { + $this->behaviour = $behaviour; + return $this; + } + + public function getChallenge(): string + { + return $this->getAuthenticationScheme() . ' realm="' . addslashes($this->getName()) . '"'; + } + + /** + * @return string + */ + #[SymfonySerializer\Groups(['get', 'realm', 'web_response'])] + #[Serializer\Groups(['get', 'realm', 'web_response'])] + public function getAuthenticationScheme(): string + { + switch ($this->getType()) { + case RealmInterface::TYPE_PLAIN_PASSWORD: + return 'PasswordQuery'; + default: + return 'Bearer'; + } + } + + /** + * @return string + */ + public function getType(): string + { + return $this->type; + } + + /** + * @param string $type + * @return Realm + */ + public function setType(string $type): Realm + { + $this->type = $type; + return $this; + } +} diff --git a/lib/RoadizCoreBundle/src/Entity/RealmNode.php b/lib/RoadizCoreBundle/src/Entity/RealmNode.php new file mode 100644 index 00000000..d51a8b69 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Entity/RealmNode.php @@ -0,0 +1,109 @@ +node; + } + + /** + * @param Node $node + * @return RealmNode + */ + public function setNode(Node $node): RealmNode + { + $this->node = $node; + return $this; + } + + /** + * @return Realm|null + */ + public function getRealm(): ?Realm + { + return $this->realm; + } + + /** + * @param Realm|null $realm + * @return RealmNode + */ + public function setRealm(?Realm $realm): RealmNode + { + $this->realm = $realm; + return $this; + } + + /** + * @return string + */ + public function getInheritanceType(): string + { + return $this->inheritanceType; + } + + /** + * @param string $inheritanceType + * @return RealmNode + */ + public function setInheritanceType(string $inheritanceType): RealmNode + { + $this->inheritanceType = $inheritanceType; + return $this; + } +} diff --git a/lib/RoadizCoreBundle/src/Entity/Redirection.php b/lib/RoadizCoreBundle/src/Entity/Redirection.php new file mode 100644 index 00000000..2428d2e9 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Entity/Redirection.php @@ -0,0 +1,143 @@ +query; + } + + /** + * @param string $query + * @return Redirection + */ + public function setQuery($query): Redirection + { + $this->query = $query; + return $this; + } + + /** + * @return string|null + */ + public function getRedirectUri(): ?string + { + return $this->redirectUri; + } + + /** + * @param string|null $redirectUri + * @return Redirection + */ + public function setRedirectUri($redirectUri): Redirection + { + $this->redirectUri = $redirectUri; + return $this; + } + + /** + * @return NodesSources|null + */ + public function getRedirectNodeSource(): ?NodesSources + { + return $this->redirectNodeSource; + } + + /** + * @param NodesSources|null $redirectNodeSource + * @return Redirection + */ + public function setRedirectNodeSource(NodesSources $redirectNodeSource = null): Redirection + { + $this->redirectNodeSource = $redirectNodeSource; + return $this; + } + + /** + * @return int + */ + public function getType(): int + { + return $this->type; + } + + /** + * @return string + */ + public function getTypeAsString(): string + { + $types = [ + Response::HTTP_MOVED_PERMANENTLY => 'redirection.moved_permanently', + Response::HTTP_FOUND => 'redirection.moved_temporarily', + ]; + + return $types[$this->type] ?? ''; + } + + /** + * @param int $type + * @return Redirection + */ + public function setType(int $type): Redirection + { + $this->type = $type; + return $this; + } + + public function __construct() + { + $this->type = Response::HTTP_MOVED_PERMANENTLY; + $this->initAbstractDateTimed(); + } +} diff --git a/lib/RoadizCoreBundle/src/Entity/Role.php b/lib/RoadizCoreBundle/src/Entity/Role.php new file mode 100644 index 00000000..83264b28 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Entity/Role.php @@ -0,0 +1,230 @@ + + */ + #[ORM\ManyToMany(targetEntity: Group::class, mappedBy: 'roleEntities', cascade: ['persist', 'merge'])] + #[SymfonySerializer\Groups(['role'])] + #[Serializer\Groups(['role'])] + #[Serializer\Accessor(getter: "getGroups", setter: "setGroups")] + #[Serializer\Type("ArrayCollection")] + private Collection $groups; + + /** + * Create a new Role with its string representation. + * + * @param string $name Role name + */ + public function __construct(string $name) + { + $this->setRole($name); + $this->groups = new ArrayCollection(); + } + + /** + * @return int|null + */ + public function getId(): ?int + { + return $this->id; + } + + /** + * @param int|null $id + * @return Role + */ + public function setId(?int $id): Role + { + $this->id = $id; + return $this; + } + + /** + * @return string + * @deprecated Use getRole method + */ + public function getName(): string + { + return $this->name; + } + + /** + * @param string $name + * @return Role + * @deprecated Use setRole method + */ + public function setName(string $name): Role + { + return $this->setRole($name); + } + + /** + * @param string $role + * @return Role + */ + public function setRole(string $role): Role + { + $this->name = static::cleanName($role); + return $this; + } + + /** + * @param string $name + * + * @return string + */ + public static function cleanName(string $name): string + { + $string = (new UnicodeString($name)) + ->ascii() + ->folded() + ->snake() + ->lower() + ; + if (!$string->startsWith('role_')) { + $string = $string->prepend('role_'); + } + + return $string->upper()->toString(); + } + + /** + * @param Group $group + * @return $this + */ + public function addGroup(Group $group): Role + { + if (!$this->getGroups()->contains($group)) { + $this->getGroups()->add($group); + } + + return $this; + } + + /** + * @return Collection + */ + public function getGroups(): Collection + { + return $this->groups; + } + + /** + * @param Collection $groups + * @return $this + */ + public function setGroups(Collection $groups): Role + { + $this->groups = $groups; + /** @var Group $group */ + foreach ($this->groups as $group) { + $group->addRoleEntity($this); + } + + return $this; + } + + /** + * @param Group $group + * @return $this + */ + public function removeGroup(Group $group): Role + { + if ($this->getGroups()->contains($group)) { + $this->getGroups()->removeElement($group); + } + + return $this; + } + + /** + * Get a classified version of current role name. + * + * It replaces underscores by dashes and lowercase. + * + * @return string + * @Serializer\Groups({"role"}) + */ + #[SymfonySerializer\Groups(['role'])] + public function getClassName(): string + { + return str_replace('_', '-', strtolower($this->getRole())); + } + + /** + * @return string + */ + public function getRole(): string + { + return $this->name; + } + + /** + * @return bool + */ + public function required(): bool + { + if ( + $this->getRole() == static::ROLE_DEFAULT || + $this->getRole() == static::ROLE_SUPERADMIN || + $this->getRole() == static::ROLE_BACKEND_USER + ) { + return true; + } + + return false; + } + + /** + * @return string + */ + public function __toString(): string + { + return $this->getRole(); + } +} diff --git a/lib/RoadizCoreBundle/src/Entity/Setting.php b/lib/RoadizCoreBundle/src/Entity/Setting.php new file mode 100644 index 00000000..d9e59a85 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Entity/Setting.php @@ -0,0 +1,340 @@ + + */ + #[SymfonySerializer\Ignore] + #[Serializer\Exclude] + public static array $typeToHuman = [ + AbstractField::STRING_T => 'string.type', + AbstractField::DATETIME_T => 'date-time.type', + AbstractField::TEXT_T => 'text.type', + AbstractField::MARKDOWN_T => 'markdown.type', + AbstractField::BOOLEAN_T => 'boolean.type', + AbstractField::INTEGER_T => 'integer.type', + AbstractField::DECIMAL_T => 'decimal.type', + AbstractField::EMAIL_T => 'email.type', + AbstractField::DOCUMENTS_T => 'documents.type', + AbstractField::COLOUR_T => 'colour.type', + AbstractField::JSON_T => 'json.type', + AbstractField::CSS_T => 'css.type', + AbstractField::YAML_T => 'yaml.type', + AbstractField::ENUM_T => 'single-choice.type', + AbstractField::MULTIPLE_T => 'multiple-choice.type', + ]; + + #[ORM\Column(type: 'string', unique: true)] + #[SymfonySerializer\Groups(['setting', 'nodes_sources'])] + #[Serializer\Groups(['setting', 'nodes_sources'])] + #[Assert\NotBlank] + #[Assert\Length(max: 250)] + private string $name = ''; + + #[ORM\Column(type: 'text', unique: false, nullable: true)] + #[SymfonySerializer\Groups(['setting'])] + #[Serializer\Groups(['setting'])] + private ?string $description = null; + + #[ORM\Column(type: 'text', nullable: true)] + #[SymfonySerializer\Groups(['setting', 'nodes_sources'])] + #[Serializer\Groups(['setting', 'nodes_sources'])] + private ?string $value = null; + + /** + * Holds clear setting value after value is decoded by postLoad Doctrine event. + * + * READ ONLY: Not persisted value to hold clear value if setting is encrypted. + */ + #[SymfonySerializer\Ignore] + #[Serializer\Exclude] + private ?string $clearValue = null; + + #[ORM\Column(type: 'boolean', nullable: false, options: ['default' => true])] + #[SymfonySerializer\Groups(['setting'])] + #[Serializer\Groups(['setting'])] + private bool $visible = true; + + #[ORM\Column(type: 'boolean', nullable: false, options: ['default' => false])] + #[SymfonySerializer\Groups(['setting'])] + #[Serializer\Groups(['setting'])] + private bool $encrypted = false; + + #[ORM\ManyToOne( + targetEntity: SettingGroup::class, + cascade: ['persist', 'merge'], + fetch: 'EAGER', + inversedBy: 'settings' + )] + #[ORM\JoinColumn(name: 'setting_group_id', referencedColumnName: 'id', onDelete: 'SET NULL')] + #[SymfonySerializer\Groups(['setting'])] + #[Serializer\Groups(['setting'])] + #[Serializer\AccessType(type: 'public_method')] + #[Serializer\Accessor(getter: "getSettingGroup", setter: "setSettingGroup")] + private ?SettingGroup $settingGroup; + + /** + * Value types. + * Use NodeTypeField types constants. + */ + #[ORM\Column(type: 'integer')] + #[SymfonySerializer\Groups(['setting'])] + #[Serializer\Groups(['setting'])] + private int $type = AbstractField::STRING_T; + + /** + * Available values for ENUM and MULTIPLE setting types. + */ + #[ORM\Column(name: 'defaultValues', type: 'text', nullable: true)] + #[SymfonySerializer\Groups(['setting'])] + #[Serializer\Groups(['setting'])] + private ?string $defaultValues; + + /** + * @return string + */ + public function getName(): string + { + return $this->name; + } + + /** + * @param string|null $name + * + * @return $this + */ + public function setName(?string $name) + { + $this->name = trim(strtolower($name ?? '')); + $this->name = (new UnicodeString($this->name)) + ->ascii() + ->toString(); + $this->name = preg_replace('#([^a-z])#', '_', $this->name); + + return $this; + } + + /** + * @return string|null + */ + public function getDescription(): ?string + { + return $this->description; + } + + /** + * @param string|null $description + * + * @return Setting + */ + public function setDescription(?string $description): Setting + { + $this->description = $description; + + return $this; + } + + /** + * @return string|null + */ + public function getRawValue(): ?string + { + return $this->value; + } + + /** + * Getter for setting value OR clear value, if encrypted. + * + * @return string|bool|\DateTime|int|null + * @throws \Exception + */ + #[SymfonySerializer\Ignore] + public function getValue() + { + if ($this->isEncrypted()) { + $value = $this->clearValue; + } else { + $value = $this->value; + } + + if ($this->getType() == AbstractField::BOOLEAN_T) { + return (bool) $value; + } + + if (null !== $value) { + if ($this->getType() == AbstractField::DATETIME_T) { + return new \DateTime($value); + } + if ($this->getType() == AbstractField::DOCUMENTS_T) { + return (int) $value; + } + } + + return $value; + } + + /** + * @param null|mixed $value + * + * @return $this + */ + public function setValue($value) + { + if (null === $value) { + $this->value = null; + } elseif ( + ($this->getType() === AbstractField::DATETIME_T || $this->getType() === AbstractField::DATE_T) && + $value instanceof \DateTime + ) { + $this->value = $value->format('c'); // $value is instance of \DateTime + } else { + $this->value = (string) $value; + } + + return $this; + } + + /** + * @return bool + */ + public function isEncrypted(): bool + { + return $this->encrypted; + } + + /** + * @param bool $encrypted + * + * @return Setting + */ + public function setEncrypted(bool $encrypted): Setting + { + $this->encrypted = $encrypted; + + return $this; + } + + /** + * @return int + */ + public function getType(): int + { + return $this->type; + } + + /** + * @param int $type + * + * @return $this + */ + public function setType(int $type) + { + $this->type = $type; + + return $this; + } + + /** + * Holds clear setting value after value is decoded by postLoad Doctrine event. + * + * @param string|null $clearValue + * + * @return Setting + */ + public function setClearValue(?string $clearValue): Setting + { + $this->clearValue = $clearValue; + + return $this; + } + + /** + * @return boolean + */ + public function isVisible(): bool + { + return $this->visible; + } + + /** + * @param bool $visible + * + * @return $this + */ + public function setVisible(bool $visible) + { + $this->visible = $visible; + + return $this; + } + + /** + * @return SettingGroup|null + */ + public function getSettingGroup(): ?SettingGroup + { + return $this->settingGroup; + } + + /** + * @param SettingGroup|null $settingGroup + * + * @return $this + */ + public function setSettingGroup(?SettingGroup $settingGroup) + { + $this->settingGroup = $settingGroup; + + return $this; + } + + /** + * @return string|null + */ + public function getDefaultValues(): ?string + { + return $this->defaultValues; + } + + /** + * @param string|null $defaultValues + * + * @return Setting + */ + public function setDefaultValues(?string $defaultValues) + { + $this->defaultValues = $defaultValues; + + return $this; + } +} diff --git a/lib/RoadizCoreBundle/src/Entity/SettingGroup.php b/lib/RoadizCoreBundle/src/Entity/SettingGroup.php new file mode 100644 index 00000000..2b1b34c3 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Entity/SettingGroup.php @@ -0,0 +1,124 @@ + false])] + #[SymfonySerializer\Groups(['setting', 'setting_group'])] + #[Serializer\Groups(['setting', 'setting_group'])] + protected bool $inMenu = false; + + #[ORM\Column(type: 'string', unique: true)] + #[SymfonySerializer\Groups(['setting', 'setting_group'])] + #[Serializer\Groups(['setting', 'setting_group'])] + #[Assert\NotNull] + #[Assert\NotBlank] + #[Assert\Length(max: 250)] + private string $name = ''; + + /** + * @var Collection + */ + #[ORM\OneToMany(mappedBy: 'settingGroup', targetEntity: Setting::class)] + #[SymfonySerializer\Groups(['setting_group'])] + #[Serializer\Groups(['setting_group'])] + private Collection $settings; + + public function __construct() + { + $this->settings = new ArrayCollection(); + } + + /** + * @return string + */ + public function getName(): string + { + return $this->name; + } + + /** + * @param string $name + * + * @return SettingGroup + */ + public function setName(string $name) + { + $this->name = $name; + return $this; + } + + /** + * @return bool + */ + public function isInMenu(): bool + { + return $this->inMenu; + } + + /** + * @param bool $newinMenu + * @return SettingGroup + */ + public function setInMenu(bool $newinMenu) + { + $this->inMenu = $newinMenu; + + return $this; + } + + /** + * @param Setting $setting + * @return SettingGroup + */ + public function addSetting(Setting $setting) + { + if (!$this->getSettings()->contains($setting)) { + $this->settings->add($setting); + } + return $this; + } + + /** + * @return Collection + */ + public function getSettings() + { + return $this->settings; + } + + /** + * @param Collection $settings + * @return SettingGroup + */ + public function addSettings(Collection $settings) + { + foreach ($settings as $setting) { + if (!$this->getSettings()->contains($setting)) { + $this->settings->add($setting); + } + } + return $this; + } +} diff --git a/lib/RoadizCoreBundle/src/Entity/Tag.php b/lib/RoadizCoreBundle/src/Entity/Tag.php new file mode 100644 index 00000000..c991c7d8 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Entity/Tag.php @@ -0,0 +1,449 @@ + '#000000'] + )] + #[SymfonySerializer\Groups(['tag', 'tag_base', 'color'])] + #[Serializer\Groups(['tag', 'tag_base', 'color'])] + protected string $color = '#000000'; + + /** + * @var Tag|null + */ + #[ApiFilter(BaseFilter\SearchFilter::class, properties: [ + "parent.id" => "exact", + "parent.tagName" => "exact" + ])] + #[ApiFilter(NotFilter::class, properties: [ + "parent.id", + "parent.tagName" + ])] + #[ORM\ManyToOne(targetEntity: Tag::class, fetch: 'EXTRA_LAZY', inversedBy: 'children')] + #[ORM\JoinColumn(name: 'parent_tag_id', referencedColumnName: 'id', onDelete: 'CASCADE')] + #[Serializer\Exclude] + #[SymfonySerializer\MaxDepth(2)] + #[SymfonySerializer\Groups(['tag_parent'])] + protected ?LeafInterface $parent = null; + + /** + * @var Collection + */ + #[ORM\OneToMany( + mappedBy: 'parent', + targetEntity: Tag::class, + cascade: ['persist', 'merge'], + orphanRemoval: true + )] + #[ORM\OrderBy(['position' => 'ASC'])] + #[SymfonySerializer\Groups(['tag_children'])] + #[Serializer\Groups(['tag_children'])] + #[Serializer\AccessType(type: "public_method")] + protected Collection $children; + + /** + * @var Collection + */ + #[ORM\OneToMany( + mappedBy: 'tag', + targetEntity: TagTranslation::class, + cascade: ['all'], + fetch: 'EAGER', + orphanRemoval: true + )] + #[SymfonySerializer\Groups(['translated_tag'])] + #[Serializer\Groups(['translated_tag'])] + protected Collection $translatedTags; + + #[ApiFilter(BaseFilter\SearchFilter::class, strategy: "partial")] + #[ORM\Column(name: 'tag_name', type: 'string', unique: true)] + #[SymfonySerializer\Ignore] + #[Serializer\Groups(['tag'])] + #[Serializer\Accessor(getter: "getTagName", setter: "setTagName")] + #[Assert\NotNull] + #[Assert\NotBlank] + #[Assert\Length(max: 250)] + private string $tagName = ''; + + #[SymfonySerializer\Ignore] + #[Serializer\Exclude] + private string $dirtyTagName = ''; + + #[ApiFilter(BaseFilter\BooleanFilter::class)] + #[ORM\Column(type: 'boolean', nullable: false, options: ['default' => true])] + #[SymfonySerializer\Groups(['tag', 'tag_base', 'node', 'nodes_sources'])] + #[Serializer\Groups(['tag', 'tag_base', 'node', 'nodes_sources'])] + private bool $visible = true; + + #[ORM\Column(name: 'children_order', type: 'string', options: ['default' => 'position'])] + #[SymfonySerializer\Ignore] + #[Serializer\Groups(["tag"])] + private string $childrenOrder = 'position'; + + #[ORM\Column(name: 'children_order_direction', type: 'string', length: 4, options: ['default' => 'ASC'])] + #[SymfonySerializer\Ignore] + #[Serializer\Groups(["tag"])] + private string $childrenOrderDirection = 'ASC'; + + #[ApiFilter(BaseFilter\BooleanFilter::class)] + #[ORM\Column(type: 'boolean', nullable: false, options: ['default' => false])] + #[SymfonySerializer\Ignore] + #[Serializer\Groups(["tag"])] + private bool $locked = false; + + /** + * @var Collection + */ + #[ORM\OneToMany( + mappedBy: 'tag', + targetEntity: NodesTags::class, + cascade: ['persist'], + orphanRemoval: true + )] + #[ORM\OrderBy(['position' => 'ASC'])] + #[SymfonySerializer\Ignore] + #[Serializer\Exclude] + #[ApiFilter(BaseFilter\SearchFilter::class, properties: [ + "nodesTags.node" => "exact", + "nodesTags.node.nodeName" => "exact", + "nodesTags.node.nodeType" => "exact", + "nodesTags.node.nodeType.name" => "exact", + ])] + #[ApiFilter(BaseFilter\BooleanFilter::class, properties: [ + "nodesTags.node.visible", + "nodesTags.node.nodeType.reachable", + ])] + private Collection $nodesTags; + + /** + * Create a new Tag. + */ + public function __construct() + { + $this->nodesTags = new ArrayCollection(); + $this->translatedTags = new ArrayCollection(); + $this->children = new ArrayCollection(); + $this->initAbstractDateTimed(); + } + + /** + * Gets the value of dirtyTagName. + * + * @return string + */ + public function getDirtyTagName(): string + { + return $this->dirtyTagName; + } + + /** + * @return boolean + */ + public function isLocked(): bool + { + return $this->locked; + } + + /** + * @param boolean $locked + * + * @return $this + */ + public function setLocked(bool $locked): static + { + $this->locked = $locked; + + return $this; + } + + /** + * @return Collection + */ + public function getNodes(): Collection + { + return $this->nodesTags->map(function (NodesTags $nodesTags) { + return $nodesTags->getNode(); + }); + } + + /** + * Get tag full path using tag names. + * + * @return string + */ + public function getFullPath(): string + { + $parents = $this->getParents(); + $path = []; + + foreach ($parents as $parent) { + $path[] = $parent->getTagName(); + } + + $path[] = $this->getTagName(); + + return implode('/', $path); + } + + /** + * @return string + */ + public function getTagName(): string + { + return $this->tagName; + } + + /** + * @param string $tagName + * + * @return $this + */ + public function setTagName(string $tagName): static + { + $this->dirtyTagName = $tagName; + $this->tagName = StringHandler::slugify($tagName); + + return $this; + } + + /** + * @param TranslationInterface $translation + * @return Collection + */ + #[SymfonySerializer\Ignore] + public function getTranslatedTagsByTranslation(TranslationInterface $translation): Collection + { + return $this->translatedTags->filter(function (TagTranslation $tagTranslation) use ($translation) { + return $tagTranslation->getTranslation()->getLocale() === $translation->getLocale(); + }); + } + + /** + * @return string + */ + public function getOneLineSummary(): string + { + return $this->getId() . " — " . $this->getTagName() . + " — Visible : " . ($this->isVisible() ? 'true' : 'false') . PHP_EOL; + } + + /** + * @return boolean + */ + public function isVisible(): bool + { + return $this->visible; + } + + /** + * @param boolean $visible + * + * @return $this + */ + public function setVisible(bool $visible): static + { + $this->visible = $visible; + return $this; + } + + /** + * Gets the value of color. + * + * @return string + */ + public function getColor(): string + { + return $this->color; + } + + /** + * Sets the value of color. + * + * @param string|null $color the color + * + * @return static + */ + public function setColor(?string $color): static + { + $this->color = $color ?? ''; + + return $this; + } + + /** + * Gets the value of childrenOrder. + * + * @return string + */ + public function getChildrenOrder(): string + { + return $this->childrenOrder; + } + + /** + * Sets the value of childrenOrder. + * + * @param string $childrenOrder the children order + * + * @return static + */ + public function setChildrenOrder(string $childrenOrder): static + { + $this->childrenOrder = $childrenOrder; + + return $this; + } + + /** + * Gets the value of childrenOrderDirection. + * + * @return string + */ + public function getChildrenOrderDirection(): string + { + return $this->childrenOrderDirection; + } + + /** + * Sets the value of childrenOrderDirection. + * + * @param string $childrenOrderDirection the children order direction + * + * @return static + */ + public function setChildrenOrderDirection(string $childrenOrderDirection): static + { + $this->childrenOrderDirection = $childrenOrderDirection; + + return $this; + } + + /** + * @return string + */ + public function __toString(): string + { + return (string) $this->getId(); + } + + /** + * @return string|null + * + * @Serializer\Groups({"tag", "tag_base", "node", "nodes_sources"}) + * @Serializer\VirtualProperty + * @Serializer\Type("string|null") + */ + #[SymfonySerializer\Ignore] + public function getName(): ?string + { + return $this->getTranslatedTags()->first() ? + $this->getTranslatedTags()->first()->getName() : + $this->getTagName(); + } + + /** + * @return Collection + */ + public function getTranslatedTags(): Collection + { + return $this->translatedTags; + } + + /** + * @param Collection $translatedTags + * @return $this + */ + public function setTranslatedTags(Collection $translatedTags): static + { + $this->translatedTags = $translatedTags; + /** @var TagTranslation $translatedTag */ + foreach ($this->translatedTags as $translatedTag) { + $translatedTag->setTag($this); + } + return $this; + } + + /** + * @return string|null + * + * @Serializer\Groups({"tag", "node", "nodes_sources"}) + * @Serializer\VirtualProperty + * @Serializer\Type("string|null") + */ + #[SymfonySerializer\Ignore] + public function getDescription(): ?string + { + return $this->getTranslatedTags()->first() ? + $this->getTranslatedTags()->first()->getDescription() : + ''; + } + + /** + * @return array + * + * @Serializer\Groups({"tag", "node", "nodes_sources"}) + * @Serializer\VirtualProperty + * @Serializer\Type("array") + */ + #[SymfonySerializer\Ignore] + public function getDocuments(): array + { + return $this->getTranslatedTags()->first() ? + $this->getTranslatedTags()->first()->getDocuments() : + []; + } +} diff --git a/lib/RoadizCoreBundle/src/Entity/TagTranslation.php b/lib/RoadizCoreBundle/src/Entity/TagTranslation.php new file mode 100644 index 00000000..85858fab --- /dev/null +++ b/lib/RoadizCoreBundle/src/Entity/TagTranslation.php @@ -0,0 +1,233 @@ + + * @Serializer\Exclude + */ + #[ORM\OneToMany( + mappedBy: 'tagTranslation', + targetEntity: TagTranslationDocuments::class, + cascade: ['persist', 'merge'], + orphanRemoval: true + )] + #[ORM\OrderBy(['position' => 'ASC'])] + #[SymfonySerializer\Ignore] + protected Collection $tagTranslationDocuments; + + /** + * Create a new TagTranslation with its origin Tag and Translation. + * + * @param Tag|null $original + * @param TranslationInterface|null $translation + */ + public function __construct(Tag $original = null, TranslationInterface $translation = null) + { + $this->setTag($original); + $this->setTranslation($translation); + $this->tagTranslationDocuments = new ArrayCollection(); + + if (null !== $original) { + $this->name = $original->getDirtyTagName() != '' ? $original->getDirtyTagName() : $original->getTagName(); + } + } + + /** + * @return string + */ + public function getName(): string + { + return $this->name; + } + + /** + * @param string|null $name + * + * @return $this + */ + public function setName(?string $name): TagTranslation + { + $this->name = $name ?? ''; + + return $this; + } + + /** + * @return string + */ + public function getDescription(): ?string + { + return $this->description; + } + + /** + * @param string|null $description + * + * @return $this + */ + public function setDescription(?string $description): TagTranslation + { + $this->description = $description; + + return $this; + } + + /** + * Gets the value of tag. + * + * @return Tag + */ + public function getTag(): ?Tag + { + return $this->tag; + } + + /** + * Sets the value of tag. + * + * @param Tag|null $tag the tag + * + * @return self + */ + public function setTag(?Tag $tag): TagTranslation + { + $this->tag = $tag; + + return $this; + } + + /** + * Gets the value of translation. + * + * @return TranslationInterface|null + */ + public function getTranslation(): ?TranslationInterface + { + return $this->translation; + } + + /** + * Sets the value of translation. + * + * @param TranslationInterface|null $translation the translation + * + * @return self + */ + public function setTranslation(?TranslationInterface $translation): TagTranslation + { + $this->translation = $translation; + + return $this; + } + + /** + * After clone method. + * + * Be careful not to persist nor flush current entity after + * calling clone as it empties its relations. + */ + public function __clone() + { + if ($this->id) { + $this->id = null; + $documents = $this->getDocuments(); + if ($documents !== null) { + $this->tagTranslationDocuments = new ArrayCollection(); + /** @var TagTranslationDocuments $document */ + foreach ($documents as $document) { + $cloneDocument = clone $document; + $this->tagTranslationDocuments->add($cloneDocument); + $cloneDocument->setTagTranslation($this); + } + } + } + } + + /** + * @return array + * + * @Serializer\Groups({"tag"}) + * @Serializer\VirtualProperty + * @Serializer\Type("array") + */ + #[SymfonySerializer\Groups(['tag'])] + public function getDocuments(): array + { + return array_map(function (TagTranslationDocuments $tagTranslationDocument) { + return $tagTranslationDocument->getDocument(); + }, $this->getTagTranslationDocuments()->toArray()); + } + + /** + * @return Collection + */ + public function getTagTranslationDocuments(): Collection + { + return $this->tagTranslationDocuments; + } + + /** + * @param Collection $tagTranslationDocuments + * @return TagTranslation + */ + public function setTagTranslationDocuments(Collection $tagTranslationDocuments): TagTranslation + { + $this->tagTranslationDocuments = $tagTranslationDocuments; + return $this; + } +} diff --git a/lib/RoadizCoreBundle/src/Entity/TagTranslationDocuments.php b/lib/RoadizCoreBundle/src/Entity/TagTranslationDocuments.php new file mode 100644 index 00000000..ca765fb0 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Entity/TagTranslationDocuments.php @@ -0,0 +1,105 @@ +document = $document; + $this->tagTranslation = $tagTranslation; + } + + public function __clone() + { + if ($this->id) { + $this->id = null; + $this->tagTranslation = null; + } + } + + /** + * Gets the value of document. + * + * @return Document|null + */ + public function getDocument(): ?Document + { + return $this->document; + } + + /** + * Sets the value of document. + * + * @param Document|null $document the document + * + * @return self + */ + public function setDocument(?Document $document): TagTranslationDocuments + { + $this->document = $document; + + return $this; + } + + public function getTagTranslation(): ?TagTranslation + { + return $this->tagTranslation; + } + + /** + * @param TagTranslation|null $tagTranslation + * @return TagTranslationDocuments + */ + public function setTagTranslation(?TagTranslation $tagTranslation): TagTranslationDocuments + { + $this->tagTranslation = $tagTranslation; + return $this; + } +} diff --git a/lib/RoadizCoreBundle/src/Entity/Theme.php b/lib/RoadizCoreBundle/src/Entity/Theme.php new file mode 100644 index 00000000..86807262 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Entity/Theme.php @@ -0,0 +1,177 @@ +available; + } + + /** + * @param boolean $available + * + * @return $this + */ + public function setAvailable(bool $available): Theme + { + $this->available = $available; + return $this; + } + + /** + * Static means that your theme is not suitable for responding from + * nodes urls but only static routes. + * + * @return boolean + */ + public function isStaticTheme(): bool + { + return (bool) $this->staticTheme; + } + + /** + * @param boolean $staticTheme + * @return $this + */ + public function setStaticTheme(bool $staticTheme): Theme + { + $this->staticTheme = (bool) $staticTheme; + return $this; + } + + /** + * Alias for getInformations. + * + * @return array + */ + public function getInformation(): array + { + return $this->getInformations(); + } + + /** + * Get theme information in an array. + * + * - name + * - author + * - copyright + * - dir + * + * @return array + */ + public function getInformations(): array + { + $class = $this->getClassName(); + + if (class_exists($class)) { + $reflector = new \ReflectionClass($class); + if ($reflector->isSubclassOf('\\RZ\\Roadiz\\CMS\\Controllers\\AppController')) { + return [ + 'name' => call_user_func([$class, 'getThemeName']), + 'author' => call_user_func([$class, 'getThemeAuthor']), + 'copyright' => call_user_func([$class, 'getThemeCopyright']), + 'dir' => call_user_func([$class, 'getThemeDir']) + ]; + } + } + + return []; + } + + /** + * @return class-string + */ + public function getClassName(): string + { + return $this->className; + } + + /** + * @param class-string $className + * @return $this + */ + public function setClassName(string $className): Theme + { + $this->className = $className; + return $this; + } + + /** + * @return string + */ + public function getHostname(): string + { + return $this->hostname; + } + + /** + * @param string $hostname + * + * @return $this + */ + public function setHostname(string $hostname): Theme + { + $this->hostname = $hostname; + return $this; + } + + /** + * @return string + */ + public function getRoutePrefix(): string + { + return $this->routePrefix; + } + + /** + * @param string $routePrefix + * + * @return $this + */ + public function setRoutePrefix(string $routePrefix): Theme + { + $this->routePrefix = $routePrefix; + return $this; + } + + /** + * @return boolean + */ + public function isBackendTheme(): bool + { + return $this->backendTheme; + } + + /** + * @param boolean $backendTheme + * @return $this + */ + public function setBackendTheme(bool $backendTheme): Theme + { + $this->backendTheme = $backendTheme; + return $this; + } +} diff --git a/lib/RoadizCoreBundle/src/Entity/Translation.php b/lib/RoadizCoreBundle/src/Entity/Translation.php new file mode 100644 index 00000000..480f9647 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Entity/Translation.php @@ -0,0 +1,827 @@ + "exact", + "name" => "exact" + ]) +] +class Translation extends AbstractDateTimed implements TranslationInterface +{ + /** + * Associates locales to pretty languages names. + */ + #[SymfonySerializer\Ignore] + #[Serializer\Exclude] + public static array $availableLocales = [ + 'af_NA' => "Afrikaans (Namibia)", + 'af_ZA' => "Afrikaans (South Africa)", + 'af' => "Afrikaans", + 'ak_GH' => "Akan (Ghana)", + 'ak' => "Akan", + 'sq_AL' => "Albanian (Albania)", + 'sq' => "Albanian", + 'am_ET' => "Amharic (Ethiopia)", + 'am' => "Amharic", + 'ar_DZ' => "Arabic (Algeria)", + 'ar_BH' => "Arabic (Bahrain)", + 'ar_EG' => "Arabic (Egypt)", + 'ar_IQ' => "Arabic (Iraq)", + 'ar_JO' => "Arabic (Jordan)", + 'ar_KW' => "Arabic (Kuwait)", + 'ar_LB' => "Arabic (Lebanon)", + 'ar_LY' => "Arabic (Libya)", + 'ar_MA' => "Arabic (Morocco)", + 'ar_OM' => "Arabic (Oman)", + 'ar_QA' => "Arabic (Qatar)", + 'ar_SA' => "Arabic (Saudi Arabia)", + 'ar_SD' => "Arabic (Sudan)", + 'ar_SY' => "Arabic (Syria)", + 'ar_TN' => "Arabic (Tunisia)", + 'ar_AE' => "Arabic (United Arab Emirates)", + 'ar_YE' => "Arabic (Yemen)", + 'ar' => "Arabic", + 'hy_AM' => "Armenian (Armenia)", + 'hy' => "Armenian", + 'as_IN' => "Assamese (India)", + 'as' => "Assamese", + 'asa_TZ' => "Asu (Tanzania)", + 'asa' => "Asu", + 'az_Cyrl' => "Azerbaijani (Cyrillic)", + 'az_Cyrl_AZ' => "Azerbaijani (Cyrillic, Azerbaijan)", + 'az_Latn' => "Azerbaijani (Latin)", + 'az_Latn_AZ' => "Azerbaijani (Latin, Azerbaijan)", + 'az' => "Azerbaijani", + 'bm_ML' => "Bambara (Mali)", + 'bm' => "Bambara", + 'eu_ES' => "Basque (Spain)", + 'eu' => "Basque", + 'be_BY' => "Belarusian (Belarus)", + 'be' => "Belarusian", + 'bem_ZM' => "Bemba (Zambia)", + 'bem' => "Bemba", + 'bez_TZ' => "Bena (Tanzania)", + 'bez' => "Bena", + 'bn_BD' => "Bengali (Bangladesh)", + 'bn_IN' => "Bengali (India)", + 'bn' => "Bengali", + 'bs_BA' => "Bosnian (Bosnia and Herzegovina)", + 'bs' => "Bosnian", + 'bg_BG' => "Bulgarian (Bulgaria)", + 'bg' => "Bulgarian", + 'my_MM' => "Burmese (Myanmar [Burma])", + 'my' => "Burmese", + 'ca_ES' => "Catalan (Spain)", + 'ca' => "Catalan", + 'tzm_Latn' => "Central Morocco Tamazight (Latin)", + 'tzm_Latn_MA' => "Central Morocco Tamazight (Latin, Morocco)", + 'tzm' => "Central Morocco Tamazight", + 'chr_US' => "Cherokee (United States)", + 'chr' => "Cherokee", + 'cgg_UG' => "Chiga (Uganda)", + 'cgg' => "Chiga", + 'zh_Hans' => "Chinese (Simplified Han)", + 'zh_Hans_CN' => "Chinese (Simplified Han, China)", + 'zh_Hans_HK' => "Chinese (Simplified Han, Hong Kong SAR China)", + 'zh_Hans_MO' => "Chinese (Simplified Han, Macau SAR China)", + 'zh_Hans_SG' => "Chinese (Simplified Han, Singapore)", + 'zh_Hant' => "Chinese (Traditional Han)", + 'zh_Hant_HK' => "Chinese (Traditional Han, Hong Kong SAR China)", + 'zh_Hant_MO' => "Chinese (Traditional Han, Macau SAR China)", + 'zh_Hant_TW' => "Chinese (Traditional Han, Taiwan)", + 'zh' => "Chinese", + 'kw_GB' => "Cornish (United Kingdom)", + 'kw' => "Cornish", + 'hr_HR' => "Croatian (Croatia)", + 'hr' => "Croatian", + 'cs_CZ' => "Czech (Czech Republic)", + 'cs' => "Czech", + 'da_DK' => "Danish (Denmark)", + 'da' => "Danish", + 'nl_BE' => "Dutch (Belgium)", + 'nl_NL' => "Dutch (Netherlands)", + 'nl' => "Dutch", + 'ebu_KE' => "Embu (Kenya)", + 'ebu' => "Embu", + 'en_AS' => "English (American Samoa)", + 'en_AU' => "English (Australia)", + 'en_BE' => "English (Belgium)", + 'en_BZ' => "English (Belize)", + 'en_BW' => "English (Botswana)", + 'en_CA' => "English (Canada)", + 'en_GU' => "English (Guam)", + 'en_HK' => "English (Hong Kong SAR China)", + 'en_IN' => "English (India)", + 'en_IE' => "English (Ireland)", + 'en_JM' => "English (Jamaica)", + 'en_MT' => "English (Malta)", + 'en_MH' => "English (Marshall Islands)", + 'en_MU' => "English (Mauritius)", + 'en_NA' => "English (Namibia)", + 'en_NZ' => "English (New Zealand)", + 'en_MP' => "English (Northern Mariana Islands)", + 'en_PK' => "English (Pakistan)", + 'en_PH' => "English (Philippines)", + 'en_SG' => "English (Singapore)", + 'en_ZA' => "English (South Africa)", + 'en_TT' => "English (Trinidad and Tobago)", + 'en_UM' => "English (U.S. Minor Outlying Islands)", + 'en_VI' => "English (U.S. Virgin Islands)", + 'en_GB' => "English (United Kingdom)", + 'en_US' => "English (United States)", + 'en_ZW' => "English (Zimbabwe)", + 'en' => "English", + 'eo' => "Esperanto", + 'et_EE' => "Estonian (Estonia)", + 'et' => "Estonian", + 'ee_GH' => "Ewe (Ghana)", + 'ee_TG' => "Ewe (Togo)", + 'ee' => "Ewe", + 'fo_FO' => "Faroese (Faroe Islands)", + 'fo' => "Faroese", + 'fil_PH' => "Filipino (Philippines)", + 'fil' => "Filipino", + 'fi_FI' => "Finnish (Finland)", + 'fi' => "Finnish", + 'fr_BE' => "French (Belgium)", + 'fr_BJ' => "French (Benin)", + 'fr_BF' => "French (Burkina Faso)", + 'fr_BI' => "French (Burundi)", + 'fr_CM' => "French (Cameroon)", + 'fr_CA' => "French (Canada)", + 'fr_CF' => "French (Central African Republic)", + 'fr_TD' => "French (Chad)", + 'fr_KM' => "French (Comoros)", + 'fr_CG' => "French (Congo - Brazzaville)", + 'fr_CD' => "French (Congo - Kinshasa)", + 'fr_CI' => "French (Côte d’Ivoire)", + 'fr_DJ' => "French (Djibouti)", + 'fr_GQ' => "French (Equatorial Guinea)", + 'fr_FR' => "French (France)", + 'fr_GA' => "French (Gabon)", + 'fr_GP' => "French (Guadeloupe)", + 'fr_GN' => "French (Guinea)", + 'fr_LU' => "French (Luxembourg)", + 'fr_MG' => "French (Madagascar)", + 'fr_ML' => "French (Mali)", + 'fr_MQ' => "French (Martinique)", + 'fr_MC' => "French (Monaco)", + 'fr_NE' => "French (Niger)", + 'fr_RW' => "French (Rwanda)", + 'fr_RE' => "French (Réunion)", + 'fr_BL' => "French (Saint Barthélemy)", + 'fr_MF' => "French (Saint Martin)", + 'fr_SN' => "French (Senegal)", + 'fr_CH' => "French (Switzerland)", + 'fr_TG' => "French (Togo)", + 'fr' => "French", + 'ff_SN' => "Fulah (Senegal)", + 'ff' => "Fulah", + 'gl_ES' => "Galician (Spain)", + 'gl' => "Galician", + 'lg_UG' => "Ganda (Uganda)", + 'lg' => "Ganda", + 'ka_GE' => "Georgian (Georgia)", + 'ka' => "Georgian", + 'de_AT' => "German (Austria)", + 'de_BE' => "German (Belgium)", + 'de_DE' => "German (Germany)", + 'de_LI' => "German (Liechtenstein)", + 'de_LU' => "German (Luxembourg)", + 'de_CH' => "German (Switzerland)", + 'de' => "German", + 'el_CY' => "Greek (Cyprus)", + 'el_GR' => "Greek (Greece)", + 'el' => "Greek", + 'gu_IN' => "Gujarati (India)", + 'gu' => "Gujarati", + 'guz_KE' => "Gusii (Kenya)", + 'guz' => "Gusii", + 'ha_Latn' => "Hausa (Latin)", + 'ha_Latn_GH' => "Hausa (Latin, Ghana)", + 'ha_Latn_NE' => "Hausa (Latin, Niger)", + 'ha_Latn_NG' => "Hausa (Latin, Nigeria)", + 'ha' => "Hausa", + 'haw_US' => "Hawaiian (United States)", + 'haw' => "Hawaiian", + 'he_IL' => "Hebrew (Israel)", + 'he' => "Hebrew", + 'hi_IN' => "Hindi (India)", + 'hi' => "Hindi", + 'hu_HU' => "Hungarian (Hungary)", + 'hu' => "Hungarian", + 'is_IS' => "Icelandic (Iceland)", + 'is' => "Icelandic", + 'ig_NG' => "Igbo (Nigeria)", + 'ig' => "Igbo", + 'id_ID' => "Indonesian (Indonesia)", + 'id' => "Indonesian", + 'ga_IE' => "Irish (Ireland)", + 'ga' => "Irish", + 'it_IT' => "Italian (Italy)", + 'it_CH' => "Italian (Switzerland)", + 'it' => "Italian", + 'ja_JP' => "Japanese (Japan)", + 'ja' => "Japanese", + 'kea_CV' => "Kabuverdianu (Cape Verde)", + 'kea' => "Kabuverdianu", + 'kab_DZ' => "Kabyle (Algeria)", + 'kab' => "Kabyle", + 'kl_GL' => "Kalaallisut (Greenland)", + 'kl' => "Kalaallisut", + 'kln_KE' => "Kalenjin (Kenya)", + 'kln' => "Kalenjin", + 'kam_KE' => "Kamba (Kenya)", + 'kam' => "Kamba", + 'kn_IN' => "Kannada (India)", + 'kn' => "Kannada", + 'kk_Cyrl' => "Kazakh (Cyrillic)", + 'kk_Cyrl_KZ' => "Kazakh (Cyrillic, Kazakhstan)", + 'kk' => "Kazakh", + 'km_KH' => "Khmer (Cambodia)", + 'km' => "Khmer", + 'ki_KE' => "Kikuyu (Kenya)", + 'ki' => "Kikuyu", + 'rw_RW' => "Kinyarwanda (Rwanda)", + 'rw' => "Kinyarwanda", + 'kok_IN' => "Konkani (India)", + 'kok' => "Konkani", + 'ko_KR' => "Korean (South Korea)", + 'ko' => "Korean", + 'khq_ML' => "Koyra Chiini (Mali)", + 'khq' => "Koyra Chiini", + 'ses_ML' => "Koyraboro Senni (Mali)", + 'ses' => "Koyraboro Senni", + 'lag_TZ' => "Langi (Tanzania)", + 'lag' => "Langi", + 'lv_LV' => "Latvian (Latvia)", + 'lv' => "Latvian", + 'lt_LT' => "Lithuanian (Lithuania)", + 'lt' => "Lithuanian", + 'luo_KE' => "Luo (Kenya)", + 'luo' => "Luo", + 'luy_KE' => "Luyia (Kenya)", + 'luy' => "Luyia", + 'mk_MK' => "Macedonian (Macedonia)", + 'mk' => "Macedonian", + 'jmc_TZ' => "Machame (Tanzania)", + 'jmc' => "Machame", + 'kde_TZ' => "Makonde (Tanzania)", + 'kde' => "Makonde", + 'mg_MG' => "Malagasy (Madagascar)", + 'mg' => "Malagasy", + 'ms_BN' => "Malay (Brunei)", + 'ms_MY' => "Malay (Malaysia)", + 'ms' => "Malay", + 'ml_IN' => "Malayalam (India)", + 'ml' => "Malayalam", + 'mt_MT' => "Maltese (Malta)", + 'mt' => "Maltese", + 'gv_GB' => "Manx (United Kingdom)", + 'gv' => "Manx", + 'mr_IN' => "Marathi (India)", + 'mr' => "Marathi", + 'mas_KE' => "Masai (Kenya)", + 'mas_TZ' => "Masai (Tanzania)", + 'mas' => "Masai", + 'mer_KE' => "Meru (Kenya)", + 'mer' => "Meru", + 'mfe_MU' => "Morisyen (Mauritius)", + 'mfe' => "Morisyen", + 'naq_NA' => "Nama (Namibia)", + 'naq' => "Nama", + 'ne_IN' => "Nepali (India)", + 'ne_NP' => "Nepali (Nepal)", + 'ne' => "Nepali", + 'nd_ZW' => "North Ndebele (Zimbabwe)", + 'nd' => "North Ndebele", + 'nb_NO' => "Norwegian Bokmål (Norway)", + 'nb' => "Norwegian Bokmål", + 'nn_NO' => "Norwegian Nynorsk (Norway)", + 'nn' => "Norwegian Nynorsk", + 'nyn_UG' => "Nyankole (Uganda)", + 'nyn' => "Nyankole", + 'or_IN' => "Oriya (India)", + 'or' => "Oriya", + 'om_ET' => "Oromo (Ethiopia)", + 'm_KE' => "Oromo (Kenya)", + 'om' => "Oromo", + 'ps_AF' => "Pashto (Afghanistan)", + 'ps' => "Pashto", + 'fa_AF' => "Persian (Afghanistan)", + 'fa_IR' => "Persian (Iran)", + 'fa' => "Persian", + 'pl_PL' => "Polish (Poland)", + 'pl' => "Polish", + 'pt_BR' => "Portuguese (Brazil)", + 'pt_GW' => "Portuguese (Guinea-Bissau)", + 'pt_MZ' => "Portuguese (Mozambique)", + 'pt_PT' => "Portuguese (Portugal)", + 'pt' => "Portuguese", + 'pa_Arab' => "Punjabi (Arabic)", + 'pa_Arab_PK' => "Punjabi (Arabic, Pakistan)", + 'pa_Guru' => "Punjabi (Gurmukhi)", + 'pa_Guru_IN' => "Punjabi (Gurmukhi, India)", + 'pa' => "Punjabi", + 'ro_MD' => "Romanian (Moldova)", + 'ro_RO' => "Romanian (Romania)", + 'ro' => "Romanian", + 'rm_CH' => "Romansh (Switzerland)", + 'rm' => "Romansh", + 'rof_TZ' => "Rombo (Tanzania)", + 'rof' => "Rombo", + 'ru_MD' => "Russian (Moldova)", + 'ru_RU' => "Russian (Russia)", + 'ru_UA' => "Russian (Ukraine)", + 'ru' => "Russian", + 'rwk_TZ' => "Rwa (Tanzania)", + 'rwk' => "Rwa", + 'saq_KE' => "Samburu (Kenya)", + 'saq' => "Samburu", + 'sg_CF' => "Sango (Central African Republic)", + 'sg' => "Sango", + 'seh_MZ' => "Sena (Mozambique)", + 'seh' => "Sena", + 'sr_Cyrl' => "Serbian (Cyrillic)", + 'sr_Cyrl_BA' => "Serbian (Cyrillic, Bosnia and Herzegovina)", + 'sr_Cyrl_ME' => "Serbian (Cyrillic, Montenegro)", + 'sr_Cyrl_RS' => "Serbian (Cyrillic, Serbia)", + 'sr_Latn' => "Serbian (Latin)", + 'sr_Latn_BA' => "Serbian (Latin, Bosnia and Herzegovina)", + 'sr_Latn_ME' => "Serbian (Latin, Montenegro)", + 'sr_Latn_RS' => "Serbian (Latin, Serbia)", + 'sr' => "Serbian", + 'sn_ZW' => "Shona (Zimbabwe)", + 'sn' => "Shona", + 'ii_CN' => "Sichuan Yi (China)", + 'ii' => "Sichuan Yi", + 'si_LK' => "Sinhala (Sri Lanka)", + 'si' => "Sinhala", + 'sk_SK' => "Slovak (Slovakia)", + 'sk' => "Slovak", + 'sl_SI' => "Slovenian (Slovenia)", + 'sl' => "Slovenian", + 'xog_UG' => "Soga (Uganda)", + 'xog' => "Soga", + 'so_DJ' => "Somali (Djibouti)", + 'so_ET' => "Somali (Ethiopia)", + 'so_KE' => "Somali (Kenya)", + 'so_SO' => "Somali (Somalia)", + 'so' => "Somali", + 'es_AR' => "Spanish (Argentina)", + 'es_BO' => "Spanish (Bolivia)", + 'es_CL' => "Spanish (Chile)", + 'es_CO' => "Spanish (Colombia)", + 'es_CR' => "Spanish (Costa Rica)", + 'es_DO' => "Spanish (Dominican Republic)", + 'es_EC' => "Spanish (Ecuador)", + 'es_SV' => "Spanish (El Salvador)", + 'es_GQ' => "Spanish (Equatorial Guinea)", + 'es_GT' => "Spanish (Guatemala)", + 'es_HN' => "Spanish (Honduras)", + 'es_419' => "Spanish (Latin America)", + 'es_MX' => "Spanish (Mexico)", + 'es_NI' => "Spanish (Nicaragua)", + 'es_PA' => "Spanish (Panama)", + 'es_PY' => "Spanish (Paraguay)", + 'es_PE' => "Spanish (Peru)", + 'es_PR' => "Spanish (Puerto Rico)", + 'es_ES' => "Spanish (Spain)", + 'es_US' => "Spanish (United States)", + 'es_UY' => "Spanish (Uruguay)", + 'es_VE' => "Spanish (Venezuela)", + 'es' => "Spanish", + 'sw_KE' => "Swahili (Kenya)", + 'sw_TZ' => "Swahili (Tanzania)", + 'sw' => "Swahili", + 'sv_FI' => "Swedish (Finland)", + 'sv_SE' => "Swedish (Sweden)", + 'sv' => "Swedish", + 'gsw_CH' => "Swiss German (Switzerland)", + 'gsw' => "Swiss German", + 'shi_Latn' => "Tachelhit (Latin)", + 'shi_Latn_MA' => "Tachelhit (Latin, Morocco)", + 'shi_Tfng' => "Tachelhit (Tifinagh)", + 'shi_Tfng_MA' => "Tachelhit (Tifinagh, Morocco)", + 'shi' => "Tachelhit", + 'dav_KE' => "Taita (Kenya)", + 'dav' => "Taita", + 'ta_IN' => "Tamil (India)", + 'ta_LK' => "Tamil (Sri Lanka)", + 'ta' => "Tamil", + 'te_IN' => "Telugu (India)", + 'te' => "Telugu", + 'teo_KE' => "Teso (Kenya)", + 'teo_UG' => "Teso (Uganda)", + 'teo' => "Teso", + 'th_TH' => "Thai (Thailand)", + 'th' => "Thai", + 'bo_CN' => "Tibetan (China)", + 'bo_IN' => "Tibetan (India)", + 'bo' => "Tibetan", + 'ti_ER' => "Tigrinya (Eritrea)", + 'ti_ET' => "Tigrinya (Ethiopia)", + 'ti' => "Tigrinya", + 'to_TO' => "Tonga (Tonga)", + 'to' => "Tonga", + 'tr_TR' => "Turkish (Turkey)", + 'tr' => "Turkish", + 'uk_UA' => "Ukrainian (Ukraine)", + 'uk' => "Ukrainian", + 'ur_IN' => "Urdu (India)", + 'ur_PK' => "Urdu (Pakistan)", + 'ur' => "Urdu", + 'uz_Arab' => "Uzbek (Arabic)", + 'uz_Arab_AF' => "Uzbek (Arabic, Afghanistan)", + 'uz_Cyrl' => "Uzbek (Cyrillic)", + 'uz_Cyrl_UZ' => "Uzbek (Cyrillic, Uzbekistan)", + 'uz_Latn' => "Uzbek (Latin)", + 'uz_Latn_UZ' => "Uzbek (Latin, Uzbekistan)", + 'uz' => "Uzbek", + 'vi_VN' => "Vietnamese (Vietnam)", + 'vi' => "Vietnamese", + 'vun_TZ' => "Vunjo (Tanzania)", + 'vun' => "Vunjo", + 'cy_GB' => "Welsh (United Kingdom)", + 'cy' => "Welsh", + 'yo_NG' => "Yoruba (Nigeria)", + 'yo' => "Yoruba", + 'zu_ZA' => "Zulu (South Africa)", + 'zu' => "Zulu", + ]; + + #[SymfonySerializer\Ignore] + #[Serializer\Exclude] + public static array $rtlLanguages = [ + 'ar_DZ' => "Arabic (Algeria)", + 'ar_BH' => "Arabic (Bahrain)", + 'ar_EG' => "Arabic (Egypt)", + 'ar_IQ' => "Arabic (Iraq)", + 'ar_JO' => "Arabic (Jordan)", + 'ar_KW' => "Arabic (Kuwait)", + 'ar_LB' => "Arabic (Lebanon)", + 'ar_LY' => "Arabic (Libya)", + 'ar_MA' => "Arabic (Morocco)", + 'ar_OM' => "Arabic (Oman)", + 'ar_QA' => "Arabic (Qatar)", + 'ar_SA' => "Arabic (Saudi Arabia)", + 'ar_SD' => "Arabic (Sudan)", + 'ar_SY' => "Arabic (Syria)", + 'ar_TN' => "Arabic (Tunisia)", + 'ar_AE' => "Arabic (United Arab Emirates)", + 'ar_YE' => "Arabic (Yemen)", + 'ar' => "Arabic", + 'he_IL' => "Hebrew (Israel)", + 'he' => "Hebrew", + 'ur_IN' => "Urdu (India)", + 'ur_PK' => "Urdu (Pakistan)", + 'fa_AF' => "Persian (Afghanistan)", + 'fa_IR' => "Persian (Iran)", + 'fa' => "Persian", + ]; + /** + * @var Collection + */ + #[ORM\OneToMany(mappedBy: 'translation', targetEntity: DocumentTranslation::class, fetch: 'EXTRA_LAZY', orphanRemoval: true)] + #[SymfonySerializer\Ignore] + #[Serializer\Exclude] + protected Collection $documentTranslations; + + /** + * @var Collection + */ + #[ORM\OneToMany(mappedBy: 'translation', targetEntity: FolderTranslation::class, fetch: 'EXTRA_LAZY', orphanRemoval: true)] + #[SymfonySerializer\Ignore] + #[Serializer\Exclude] + protected Collection $folderTranslations; + + /** + * Language locale + * + * fr or en for example + * + * @var string + * @Serializer\Groups({"translation", "document", "nodes_sources", "tag", "attribute", "folder", "log_sources"}) + * @Serializer\Type("string") + */ + #[ORM\Column(type: 'string', length: 10, unique: true)] + #[SymfonySerializer\Ignore] + #[Assert\NotBlank] + #[Assert\NotNull] + #[Assert\Length(max: 10)] + private string $locale = ''; + + /** + * @var string|null + * @Serializer\Groups({"translation", "document", "nodes_sources", "tag", "attribute", "folder"}) + * @Serializer\Type("string") + */ + #[ORM\Column(name: 'override_locale', type: 'string', length: 10, unique: true, nullable: true)] + #[SymfonySerializer\Ignore] + #[Assert\Length(max: 10)] + private ?string $overrideLocale = null; + + /** + * @var string + * @Serializer\Groups({"translation", "translation_base"}) + * @Serializer\Type("string") + */ + #[ORM\Column(type: 'string', unique: true)] + #[SymfonySerializer\Groups(['translation', 'translation_base'])] + #[Assert\NotNull] + #[Assert\NotBlank] + #[Assert\Length(max: 250)] + private string $name = ''; + + /** + * @var bool + * @Serializer\Groups({"translation", "translation_base"}) + * @Serializer\Type("bool") + */ + #[ORM\Column(name: 'default_translation', type: 'boolean', nullable: false, options: ['default' => false])] + #[SymfonySerializer\Groups(['translation', 'translation_base'])] + private bool $defaultTranslation = false; + + /** + * @var bool + * @Serializer\Groups({"translation", "translation_base"}) + * @Serializer\Type("bool") + */ + #[ORM\Column(type: 'boolean', nullable: false, options: ['default' => true])] + #[SymfonySerializer\Groups(['translation', 'translation_base'])] + private bool $available = true; + + /** + * @var Collection + */ + #[ORM\OneToMany( + mappedBy: 'translation', + targetEntity: NodesSources::class, + fetch: 'EXTRA_LAZY', + orphanRemoval: true + )] + #[SymfonySerializer\Ignore] + #[Serializer\Exclude] + private Collection $nodeSources; + + /** + * @var Collection + */ + #[ORM\OneToMany( + mappedBy: 'translation', + targetEntity: TagTranslation::class, + fetch: 'EXTRA_LAZY', + orphanRemoval: true + )] + #[SymfonySerializer\Ignore] + #[Serializer\Exclude] + private Collection $tagTranslations; + + public function __construct() + { + $this->nodeSources = new ArrayCollection(); + $this->tagTranslations = new ArrayCollection(); + $this->folderTranslations = new ArrayCollection(); + $this->documentTranslations = new ArrayCollection(); + $this->initAbstractDateTimed(); + } + + /** + * Return available locales in an array. + * + * @return array + */ + #[SymfonySerializer\Ignore] + public static function getAvailableLocales(): array + { + return array_keys(static::$availableLocales); + } + + /** + * @return string + */ + #[SymfonySerializer\Ignore] + public function getOneLineSummary(): string + { + return $this->getId() . " — " . $this->getName() . " (" . $this->getLocale() . ')' . + " — " . ($this->isAvailable() ? 'Enabled' : 'Disabled') . + ($this->isDefaultTranslation() ? ' - Default' : '') . PHP_EOL; + } + + /** + * @return string + */ + public function __toString(): string + { + return (string) $this->getId(); + } + + /** + * @return string + */ + public function getName(): string + { + return $this->name; + } + + /** + * @param string|null $name + * + * @return $this + */ + public function setName(?string $name): Translation + { + $this->name = $name ?? ''; + return $this; + } + + /** + * @return string + */ + public function getLocale(): string + { + return $this->locale; + } + + /** + * @param string $locale + * + * @return $this + */ + public function setLocale(string $locale): Translation + { + $this->locale = $locale; + return $this; + } + + /** + * @return boolean + */ + public function isAvailable(): bool + { + return $this->available; + } + + /** + * @param boolean $available + * + * @return $this + */ + public function setAvailable(bool $available): Translation + { + $this->available = $available; + return $this; + } + + /** + * @return boolean + */ + public function isDefaultTranslation(): bool + { + return $this->defaultTranslation; + } + + /** + * @param boolean $defaultTranslation + * + * @return $this + */ + public function setDefaultTranslation(bool $defaultTranslation): Translation + { + $this->defaultTranslation = $defaultTranslation; + return $this; + } + + /** + * @return Collection + */ + public function getNodeSources(): Collection + { + return $this->nodeSources; + } + + /** + * @return Collection + */ + public function getTagTranslations(): Collection + { + return $this->tagTranslations; + } + + /** + * @return Collection + */ + public function getDocumentTranslations(): Collection + { + return $this->documentTranslations; + } + + /** + * Gets the value of overrideLocale. + * + * @return string + */ + public function getOverrideLocale(): ?string + { + return $this->overrideLocale; + } + + /** + * Sets the value of overrideLocale. + * + * @param string|null $overrideLocale the override locale + * + * @return self + */ + public function setOverrideLocale(?string $overrideLocale): Translation + { + $this->overrideLocale = StringHandler::slugify($overrideLocale); + + return $this; + } + + /** + * Get preferred locale between overrideLocale or locale. + * + * @return string + */ + #[SymfonySerializer\SerializedName('locale')] + #[SymfonySerializer\Groups(['translation_base'])] + public function getPreferredLocale(): string + { + return !empty($this->overrideLocale) ? $this->overrideLocale : $this->locale; + } + + /** + * @return bool + */ + public function isRtl(): bool + { + return in_array($this->getLocale(), static::getRightToLeftLocales()); + } + + /** + * @return array + */ + #[SymfonySerializer\Ignore] + public static function getRightToLeftLocales(): array + { + return array_keys(static::$rtlLanguages); + } + + /** + * @return Collection + */ + #[SymfonySerializer\Ignore] + public function getFolderTranslations(): Collection + { + return $this->folderTranslations; + } +} diff --git a/lib/RoadizCoreBundle/src/Entity/UrlAlias.php b/lib/RoadizCoreBundle/src/Entity/UrlAlias.php new file mode 100644 index 00000000..b937ab13 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Entity/UrlAlias.php @@ -0,0 +1,85 @@ +setNodeSource($nodeSource); + } + + /** + * @return string + */ + public function getAlias(): string + { + return $this->alias; + } + + /** + * @param string $alias + * + * @return $this + */ + public function setAlias(string $alias): UrlAlias + { + $this->alias = StringHandler::slugify($alias); + return $this; + } + + /** + * @return NodesSources|null + */ + public function getNodeSource(): ?NodesSources + { + return $this->nodeSource; + } + + /** + * @param NodesSources|null $nodeSource + * @return $this + */ + public function setNodeSource(?NodesSources $nodeSource): UrlAlias + { + $this->nodeSource = $nodeSource; + return $this; + } +} diff --git a/lib/RoadizCoreBundle/src/Entity/User.php b/lib/RoadizCoreBundle/src/Entity/User.php new file mode 100644 index 00000000..5eae9edc --- /dev/null +++ b/lib/RoadizCoreBundle/src/Entity/User.php @@ -0,0 +1,1067 @@ + true])] + #[SymfonySerializer\Groups(['user_security'])] + protected bool $enabled = true; + + /** + * @Serializer\Groups({"user_security"}) + * @var string|null + */ + #[ORM\Column(name: 'confirmation_token', type: 'string', unique: true, nullable: true)] + #[SymfonySerializer\Groups(['user_security'])] + protected ?string $confirmationToken = null; + + /** + * @Serializer\Groups({"user_security"}) + * @var \DateTime|null + */ + #[ORM\Column(name: 'password_requested_at', type: 'datetime', nullable: true)] + #[SymfonySerializer\Groups(['user_security'])] + protected ?\DateTime $passwordRequestedAt = null; + + /** + * @Serializer\Groups({"user_personal", "log_user"}) + * @var string + */ + #[ORM\Column(type: 'string', unique: true)] + #[SymfonySerializer\Groups(['user_personal', 'log_user'])] + #[Assert\NotNull] + #[Assert\NotBlank] + #[Assert\Length(max: 200)] + private string $username = ''; + + /** + * The salt to use for hashing. + */ + #[ORM\Column(name: 'salt', type: 'string')] + #[SymfonySerializer\Ignore] + #[Serializer\Exclude] + private string $salt = ''; + + /** + * Encrypted password. + */ + #[ORM\Column(type: 'string', nullable: false)] + #[SymfonySerializer\Ignore] + #[Serializer\Exclude] + private string $password = ''; + + /** + * Plain password. Used for model validation. + * **Must not be persisted.** + * + * @var string|null + * @Serializer\Groups({"user:write"}) + * @PasswordStrength(minLength=8, minStrength=3) + */ + #[SymfonySerializer\Groups(['user:write'])] + #[Assert\NotBlank(groups: ['no_empty_password'])] + private ?string $plainPassword = null; + + /** + * @var \DateTime|null + * @Serializer\Groups({"user_security"}) + */ + #[ORM\Column(name: 'last_login', type: 'datetime', nullable: true)] + #[SymfonySerializer\Groups(['user_security'])] + private ?\DateTime $lastLogin = null; + + /** + * @var Collection + */ + #[ORM\JoinTable(name: 'users_roles')] + #[ORM\JoinColumn(name: 'user_id', referencedColumnName: 'id', onDelete: 'CASCADE')] + #[ORM\InverseJoinColumn(name: 'role_id', referencedColumnName: 'id', onDelete: 'CASCADE')] + #[ORM\ManyToMany(targetEntity: Role::class)] + #[SymfonySerializer\Ignore] + #[Serializer\Exclude] + private Collection $roleEntities; + + /** + * Names of current User roles + * to be compatible with symfony security scheme + * + * @var array|null + */ + #[SymfonySerializer\Ignore] + #[Serializer\Exclude] + private ?array $roles = null; + + /** + * @var Collection + * @Serializer\Groups({"user_group"}) + */ + #[ORM\JoinTable(name: 'users_groups')] + #[ORM\JoinColumn(name: 'user_id', referencedColumnName: 'id')] + #[ORM\InverseJoinColumn(name: 'group_id', referencedColumnName: 'id')] + #[ORM\ManyToMany(targetEntity: Group::class, inversedBy: 'users')] + #[SymfonySerializer\Groups(['user_group'])] + private Collection $groups; + + /** + * @var boolean + * @Serializer\Groups({"user_security"}) + */ + #[ORM\Column(type: 'boolean', nullable: false, options: ['default' => false])] + #[SymfonySerializer\Groups(['user_security'])] + private bool $expired = false; + + /** + * @var boolean + * @Serializer\Groups({"user_security"}) + */ + #[ORM\Column(type: 'boolean', nullable: false, options: ['default' => false])] + #[SymfonySerializer\Groups(['user_security'])] + private bool $locked = false; + + /** + * @Serializer\Groups({"user_security"}) + * @var \DateTime|null + */ + #[ORM\Column(name: 'credentials_expires_at', type: 'datetime', nullable: true)] + #[SymfonySerializer\Groups(['user_security'])] + private ?\DateTime $credentialsExpiresAt = null; + + /** + * @var boolean + * @Serializer\Groups({"user_security"}) + */ + #[ORM\Column(name: 'credentials_expired', type: 'boolean', nullable: false, options: ['default' => false])] + #[SymfonySerializer\Groups(['user_security'])] + private bool $credentialsExpired = false; + + /** + * @Serializer\Groups({"user_security"}) + * @var \DateTime|null + */ + #[ORM\Column(name: 'expires_at', type: 'datetime', nullable: true)] + #[SymfonySerializer\Groups(['user_security'])] + private ?\DateTime $expiresAt = null; + + /** + * @Serializer\Groups({"user_chroot"}) + * @var Node|null + */ + #[ORM\ManyToOne(targetEntity: Node::class)] + #[ORM\JoinColumn(name: 'chroot_id', referencedColumnName: 'id', onDelete: 'SET NULL')] + #[SymfonySerializer\Groups(['user_chroot'])] + private ?Node $chroot = null; + + /** + * @var null|string + * @Serializer\Groups({"user"}) + */ + #[ORM\Column(name: 'locale', type: 'string', length: 7, nullable: true)] + #[SymfonySerializer\Groups(['user'])] + private ?string $locale = null; + + public function __construct() + { + $this->roleEntities = new ArrayCollection(); + $this->groups = new ArrayCollection(); + $this->sendCreationConfirmationEmail(false); + $this->initAbstractDateTimed(); + + $saltGenerator = new SaltGenerator(); + $this->setSalt($saltGenerator->generateSalt()); + } + + /** + * Set if we need Roadiz to send a default email + * when User will be persisted. + * + * @param bool $sendCreationConfirmationEmail + * @return User + */ + public function sendCreationConfirmationEmail(bool $sendCreationConfirmationEmail): User + { + $this->sendCreationConfirmationEmail = $sendCreationConfirmationEmail; + return $this; + } + + /** + * Tells if we need Roadiz to send a default email + * when User will be persisted. Default: false. + * + * @return bool + */ + public function willSendCreationConfirmationEmail(): bool + { + return $this->sendCreationConfirmationEmail; + } + + /** + * Get available username data, first name and last name + * or username as a last try. + * + * @return string + * @Serializer\Exclude() + */ + #[SymfonySerializer\Ignore] + public function getIdentifier(): string + { + if ($this->getFirstName() != "" && $this->getLastName() != "") { + return $this->getFirstName() . " " . $this->getLastName(); + } elseif ($this->getFirstName() != "") { + return $this->getFirstName(); + } else { + return $this->getUsername(); + } + } + + /** + * @return string + * @Serializer\Groups({"user_identifier", "user_personal"}) + * @Serializer\VirtualProperty() + */ + #[SymfonySerializer\SerializedName('identifier')] + #[SymfonySerializer\Groups(['user_identifier', 'user_personal'])] + public function getUserIdentifier(): string + { + return $this->username; + } + + /** + * @return string $username + */ + public function getUsername(): string + { + return $this->username; + } + + /** + * @param string $username + * + * @return $this + */ + public function setUsername(string $username): User + { + $this->username = $username; + return $this; + } + + /** + * Get facebook profile name to grab public infos such as picture + * + * @return string|null + */ + public function getFacebookName(): ?string + { + return $this->facebookName; + } + + /** + * @param string|null $facebookName + * @return $this + */ + public function setFacebookName(?string $facebookName): User + { + $this->facebookName = $facebookName; + return $this; + } + + /** + * @return string|null + */ + public function getPictureUrl(): ?string + { + return $this->pictureUrl; + } + + /** + * @param string|null $pictureUrl + * @return $this + */ + public function setPictureUrl(?string $pictureUrl): User + { + $this->pictureUrl = $pictureUrl; + return $this; + } + + /** + * @return string|null + */ + public function getSalt(): ?string + { + return $this->salt; + } + + /** + * @param string $salt + * @return $this + */ + public function setSalt(string $salt): User + { + $this->salt = $salt; + return $this; + } + + /** + * @return string $password + */ + public function getPassword(): string + { + return $this->password; + } + + /** + * @param string $password + * @return $this + */ + public function setPassword(string $password): User + { + $this->password = $password; + return $this; + } + + /** + * @return string|null $plainPassword + */ + public function getPlainPassword(): ?string + { + return $this->plainPassword; + } + + /** + * @param string|null $plainPassword + * @return User + */ + public function setPlainPassword(?string $plainPassword): User + { + $this->plainPassword = $plainPassword; + if (null !== $plainPassword && $plainPassword != '') { + /* + * We MUST change password to trigger preUpdate lifeCycle event. + */ + $this->password = '--password-changed--' . uniqid(); + } + return $this; + } + + /** + * @return \DateTime $lastLogin + */ + public function getLastLogin(): ?\DateTime + { + return $this->lastLogin; + } + + /** + * @param \DateTime|null $lastLogin + * @return User + */ + public function setLastLogin(?\DateTime $lastLogin): User + { + $this->lastLogin = $lastLogin; + return $this; + } + + /** + * Get random string sent to the user email address in order to verify it. + * + * @return string + */ + public function getConfirmationToken(): ?string + { + return $this->confirmationToken; + } + + /** + * Set random string sent to the user email address in order to verify it. + * + * @param string|null $confirmationToken + * @return $this + */ + public function setConfirmationToken(?string $confirmationToken): User + { + $this->confirmationToken = $confirmationToken; + + return $this; + } + + /** + * Check if password reset request has expired. + * + * @param int $ttl Password request time to live. + * + * @return boolean + */ + public function isPasswordRequestNonExpired(int $ttl): bool + { + return $this->getPasswordRequestedAt() instanceof \DateTime && + $this->getPasswordRequestedAt()->getTimestamp() + $ttl > time(); + } + + /** + * Gets the timestamp that the user requested a password reset. + * + * @return null|\DateTime + */ + public function getPasswordRequestedAt(): ?\DateTime + { + return $this->passwordRequestedAt; + } + + /** + * Sets the timestamp that the user requested a password reset. + * + * @param \DateTime|null $date + * @return $this + */ + public function setPasswordRequestedAt(\DateTime $date = null): User + { + $this->passwordRequestedAt = $date; + return $this; + } + + /** + * @param Role $role + * @return $this + * @deprecated Use addRoleEntity + */ + public function addRole(Role $role): User + { + return $this->addRoleEntity($role); + } + + /** + * Add a role object to current user. + * + * @param Role $role + * + * @return $this + */ + public function addRoleEntity(Role $role): User + { + if (!$this->getRolesEntities()->contains($role)) { + $this->getRolesEntities()->add($role); + } + + return $this; + } + + /** + * Get roles entities + * + * @return Collection + */ + public function getRolesEntities(): ?Collection + { + return $this->roleEntities; + } + + /** + * @param ArrayCollection $roles + * @return User + */ + public function setRolesEntities(ArrayCollection $roles): User + { + $this->roleEntities = $roles; + return $this; + } + + /** + * @param Role $role + * @return $this + * @deprecated Use removeRoleEntity + */ + public function removeRole(Role $role): User + { + return $this->removeRoleEntity($role); + } + + /** + * Remove role from current user. + * + * @param Role $role + * + * @return $this + */ + public function removeRoleEntity(Role $role): User + { + if ($this->getRolesEntities()->contains($role)) { + $this->getRolesEntities()->removeElement($role); + } + return $this; + } + + /** + * Removes sensitive data from the user. + * + * @return User + */ + public function eraseCredentials(): User + { + return $this->setPlainPassword(''); + } + + /** + * Insert user into group. + * + * @param Group $group + * + * @return $this + */ + public function addGroup(Group $group): User + { + if (!$this->getGroups()->contains($group)) { + $this->getGroups()->add($group); + } + + return $this; + } + + /** + * @return Collection + */ + public function getGroups(): ?Collection + { + return $this->groups; + } + + /** + * Remove user from group + * + * @param Group $group + * + * @return $this + */ + public function removeGroup(Group $group): User + { + if ($this->getGroups()->contains($group)) { + $this->getGroups()->removeElement($group); + } + + return $this; + } + + /** + * Get current user groups name. + * + * @return array Array of strings + * @Serializer\Groups({"user"}) + * @Serializer\VirtualProperty() + */ + #[SymfonySerializer\Groups(['user'])] + public function getGroupNames(): array + { + $names = []; + foreach ($this->getGroups() as $group) { + $names[] = $group->getName(); + } + + return $names; + } + + /** + * Return strictly forced expiration status. + * + * @return boolean + */ + public function getExpired(): bool + { + return $this->expired; + } + + /** + * @param boolean $expired + * @return $this + */ + public function setExpired(bool $expired): User + { + $this->expired = $expired; + + return $this; + } + + /** + * Checks whether the user's account has expired. + * + * Combines expiresAt date-time limit AND expired boolean value. + * + * Internally, if this method returns false, the authentication system + * will throw an AccountExpiredException and prevent login. + * + * @return bool true if the user's account is non expired, false otherwise + * + * @see AccountExpiredException + * @Serializer\Groups({"user_security"}) + * @Serializer\VirtualProperty() + */ + #[SymfonySerializer\Groups(['user_security'])] + public function isAccountNonExpired(): bool + { + if ( + $this->expiresAt !== null && + $this->expiresAt->getTimestamp() < time() + ) { + return false; + } + + return !$this->expired; + } + + /** + * Checks whether the user is locked. + * + * Internally, if this method returns false, the authentication system + * will throw a LockedException and prevent login. + * + * @return bool true if the user is not locked, false otherwise + * + * @see LockedException + * @Serializer\Groups({"user_security"}) + * @Serializer\VirtualProperty() + */ + #[SymfonySerializer\Groups(['user_security'])] + public function isAccountNonLocked(): bool + { + return !$this->locked; + } + + public function setLocked(bool $locked): self + { + $this->locked = $locked; + return $this; + } + + /** + * @return bool + */ + public function isLocked(): bool + { + return $this->locked; + } + + /** + * @param User $user + * + * @return boolean + */ + public function equals(User $user): bool + { + return ( + $this->username == $user->getUsername() || + $this->email == $user->getEmail() + ); + } + + /** + * @return \DateTime + */ + public function getCredentialsExpiresAt(): ?\DateTime + { + return $this->credentialsExpiresAt; + } + + /** + * @param \DateTime|null $date + * + * @return User + */ + public function setCredentialsExpiresAt(?\DateTime $date = null): User + { + $this->credentialsExpiresAt = $date; + + return $this; + } + + /** + * Return strictly forced credential expiration status. + * + * @return boolean + */ + public function getCredentialsExpired(): bool + { + return $this->credentialsExpired; + } + + /** + * @param boolean $credentialsExpired + * @return $this + */ + public function setCredentialsExpired(bool $credentialsExpired): User + { + $this->credentialsExpired = $credentialsExpired; + + return $this; + } + + /** + * @return \DateTime + */ + public function getExpiresAt(): ?\DateTime + { + return $this->expiresAt; + } + + /** + * @param \DateTime|null $date + * + * @return User + */ + public function setExpiresAt(?\DateTime $date = null): User + { + $this->expiresAt = $date; + + return $this; + } + + /** + * @return Node|null + * @internal Do use directly, use NodeChrootResolver class to support external users (SSO, oauth2, …) + */ + public function getChroot(): ?Node + { + return $this->chroot; + } + + /** + * @param Node|null $chroot + * @return User + */ + public function setChroot(Node $chroot = null): User + { + $this->chroot = $chroot; + + return $this; + } + + /** + * Get prototype abstract Gravatar url. + * + * @Serializer\Exclude() + * @param string $type Default: "identicon" + * @param string $size Default: "200" + * @return string + */ + #[SymfonySerializer\Ignore] + public function getGravatarUrl(string $type = "identicon", string $size = "200"): string + { + if (null !== $this->getEmail()) { + return "https://www.gravatar.com/avatar/" . md5(strtolower(trim($this->getEmail()))) . "?d=" . $type . "&s=" . $size; + } + return ''; + } + + /** + * @return string $text + */ + public function __toString(): string + { + return (string) $this->getId(); + } + + /** + * Checks whether the user is enabled. + * + * Internally, if this method returns false, the authentication system + * will throw a DisabledException and prevent login. + * + * @return bool true if the user is enabled, false otherwise + * + * @see DisabledException + */ + public function isEnabled(): bool + { + return $this->enabled; + } + + /** + * @param boolean $enabled + * + * @return User + */ + public function setEnabled(bool $enabled): User + { + $this->enabled = $enabled; + + return $this; + } + + /** + * Checks whether the user's credentials (password) has expired. + * + * Combines credentialsExpiresAt date-time limit AND credentialsExpired boolean value. + * + * Internally, if this method returns false, the authentication system + * will throw a CredentialsExpiredException and prevent login. + * + * @return bool true if the user's credentials are non expired, false otherwise + * + * @see CredentialsExpiredException + */ + #[SymfonySerializer\Ignore] + public function isCredentialsNonExpired(): bool + { + if ( + $this->credentialsExpiresAt !== null && + $this->credentialsExpiresAt->getTimestamp() < time() + ) { + return false; + } + + return !$this->credentialsExpired; + } + + /** + * Get roles names as a simple array, combining groups roles. + * + * @return array + */ + #[SymfonySerializer\SerializedName('roles')] + #[SymfonySerializer\Groups(['user_role'])] + public function getRoles(): array + { + if (null === $this->roles) { + $this->roles = []; + if (null !== $this->getRolesEntities()) { + foreach ($this->getRolesEntities() as $role) { + if (null !== $role) { + $this->roles[] = $role->getName(); + } + } + } + if (null !== $this->getGroups()) { + foreach ($this->getGroups() as $group) { + if ($group instanceof Group) { + // User roles > Groups roles + $this->roles = array_merge($group->getRoles(), $this->roles); + } + } + } + + // we need to make sure to have at least one role + $this->roles[] = Role::ROLE_DEFAULT; + $this->roles = array_unique($this->roles); + } + + return $this->roles; + } + + /** + * @return null|string + */ + public function getLocale(): ?string + { + return $this->locale; + } + + /** + * @param null|string $locale + * @return User + */ + public function setLocale(?string $locale): User + { + $this->locale = $locale; + return $this; + } + + public function __serialize(): array + { + return [ + $this->password, + $this->salt, + $this->username, + $this->enabled, + $this->id, + $this->email, + // needed for token roles + $this->roleEntities, + $this->groups, + // needed for advancedUserinterface + $this->expired, + $this->expiresAt, + $this->locked, + $this->credentialsExpired, + $this->credentialsExpiresAt, + ]; + } + + public function __unserialize(array $data): void + { + [ + $this->password, + $this->salt, + $this->username, + $this->enabled, + $this->id, + $this->email, + $this->roleEntities, + $this->groups, + $this->expired, + $this->expiresAt, + $this->locked, + $this->credentialsExpired, + $this->credentialsExpiresAt, + ] = $data; + } + + /** + * @Serializer\Groups({"user_security"}) + */ + #[SymfonySerializer\Groups(['user_security'])] + public function isSuperAdmin(): bool + { + return $this->hasRole(Role::ROLE_SUPERADMIN); + } + + /** + * @param string $name + * + * @return bool + */ + public function hasGroup(string $name): bool + { + return in_array((string) $name, $this->getGroupNames()); + } + + /** + * @param string $role + * + * @return bool + */ + public function hasRole(string $role): bool + { + return in_array(strtoupper((string) $role), $this->getRoles(), true); + } + + /** + * Every field tested in this methods must be serialized in token. + * + * @param UserInterface $user + * + * @return bool + */ + #[SymfonySerializer\Ignore] + public function isEqualTo(UserInterface $user): bool + { + if (!$user instanceof User) { + return false; + } + + if ($this->getId() !== $user->getId()) { + return false; + } + + if ($this->getEmail() !== $user->getEmail()) { + return false; + } + + if ($this->getPassword() !== $user->getPassword()) { + return false; + } + + if ($this->getSalt() !== $user->getSalt()) { + return false; + } + + if ($this->getUsername() !== $user->getUsername()) { + return false; + } + + if ($this->isAccountNonExpired() !== $user->isAccountNonExpired()) { + return false; + } + + if ($this->isAccountNonLocked() !== $user->isAccountNonLocked()) { + return false; + } + + if ($this->isCredentialsNonExpired() !== $user->isCredentialsNonExpired()) { + return false; + } + + if ($this->isEnabled() !== $user->isEnabled()) { + return false; + } + + if (array_diff($this->getRoles(), $user->getRoles())) { + return false; + } + + return true; + } +} diff --git a/lib/RoadizCoreBundle/src/Entity/UserLogEntry.php b/lib/RoadizCoreBundle/src/Entity/UserLogEntry.php new file mode 100644 index 00000000..d8a17915 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Entity/UserLogEntry.php @@ -0,0 +1,50 @@ + "DYNAMIC"]), + ORM\Index(columns: ["object_class"], name: "log_class_lookup_idx"), + ORM\Index(columns: ["logged_at"], name: "log_date_lookup_idx"), + ORM\Index(columns: ["username"], name: "log_user_lookup_idx"), + ORM\Index(columns: ["object_id", "object_class", "version"], name: "log_version_lookup_idx") +] +class UserLogEntry extends AbstractLogEntry +{ + /** + * @var User|null + */ + #[ORM\ManyToOne(targetEntity: 'RZ\Roadiz\CoreBundle\Entity\User')] + #[ORM\JoinColumn(name: 'user_id', referencedColumnName: 'id', unique: false, onDelete: 'SET NULL')] + protected ?User $user = null; + + /** + * @return User|null + */ + public function getUser(): ?User + { + return $this->user; + } + + /** + * @param User|null $user + * + * @return UserLogEntry + */ + public function setUser(?User $user): UserLogEntry + { + $this->user = $user; + + return $this; + } +} diff --git a/lib/RoadizCoreBundle/src/Entity/Webhook.php b/lib/RoadizCoreBundle/src/Entity/Webhook.php new file mode 100644 index 00000000..06218073 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Entity/Webhook.php @@ -0,0 +1,272 @@ + false])] + #[Serializer\Type('boolean')] + protected bool $automatic = false; + + /** + * @var Node|null + */ + #[ORM\ManyToOne(targetEntity: Node::class)] + #[ORM\JoinColumn(name: 'root_node', onDelete: 'SET NULL')] + #[SymfonySerializer\Ignore] + protected ?Node $rootNode = null; + + /** + * @return string|null + */ + public function getDescription(): ?string + { + return $this->description; + } + + /** + * @param string|null $description + * @return Webhook + */ + public function setDescription(?string $description): Webhook + { + $this->description = $description; + return $this; + } + + /** + * @return string|null + */ + public function getMessageType(): ?string + { + return $this->messageType; + } + + /** + * @param string|null $messageType + * @return Webhook + */ + public function setMessageType(?string $messageType): Webhook + { + $this->messageType = $messageType; + return $this; + } + + /** + * @return string|null + */ + public function getUri(): ?string + { + return $this->uri; + } + + /** + * @param string|null $uri + * @return Webhook + */ + public function setUri(?string $uri): Webhook + { + $this->uri = $uri; + return $this; + } + + /** + * @return array|null + */ + public function getPayload(): ?array + { + return $this->payload; + } + + /** + * @param array|null $payload + * @return Webhook + */ + public function setPayload(?array $payload): Webhook + { + $this->payload = $payload; + return $this; + } + + /** + * @return int + */ + public function getThrottleSeconds(): int + { + return $this->throttleSeconds; + } + + /** + * @return \DateInterval + * @throws \Exception + */ + public function getThrottleInterval(): \DateInterval + { + return new \DateInterval('PT' . $this->getThrottleSeconds() . 'S'); + } + + /** + * @return \DateTime|null + * @throws \Exception + */ + public function doNotTriggerBefore(): ?\DateTime + { + if (null === $this->getLastTriggeredAt()) { + return null; + } + $doNotTriggerBefore = clone $this->getLastTriggeredAt(); + return $doNotTriggerBefore->add($this->getThrottleInterval()); + } + + /** + * @param int $throttleSeconds + * @return Webhook + */ + public function setThrottleSeconds(int $throttleSeconds): Webhook + { + $this->throttleSeconds = $throttleSeconds; + return $this; + } + + /** + * @return \DateTime|null + */ + public function getLastTriggeredAt(): ?\DateTime + { + return $this->lastTriggeredAt; + } + + /** + * @param \DateTime|null $lastTriggeredAt + * @return Webhook + */ + public function setLastTriggeredAt(?\DateTime $lastTriggeredAt): Webhook + { + $this->lastTriggeredAt = $lastTriggeredAt; + return $this; + } + + /** + * @return bool + */ + public function isAutomatic(): bool + { + return $this->automatic; + } + + /** + * @param bool $automatic + * @return Webhook + */ + public function setAutomatic(bool $automatic): Webhook + { + $this->automatic = $automatic; + return $this; + } + + /** + * @return Node|null + */ + public function getRootNode(): ?Node + { + return $this->rootNode; + } + + /** + * @param Node|null $rootNode + * @return Webhook + */ + public function setRootNode(?Node $rootNode): Webhook + { + $this->rootNode = $rootNode; + return $this; + } + + public function __toString(): string + { + return (string) $this->getId(); + } +} diff --git a/lib/RoadizCoreBundle/src/EntityApi/AbstractApi.php b/lib/RoadizCoreBundle/src/EntityApi/AbstractApi.php new file mode 100644 index 00000000..6621841f --- /dev/null +++ b/lib/RoadizCoreBundle/src/EntityApi/AbstractApi.php @@ -0,0 +1,54 @@ +managerRegistry = $managerRegistry; + } + + /** + * Return entity path for current API. + * + * @return \Doctrine\ORM\EntityRepository + */ + abstract public function getRepository(); + + /** + * Return an array of entities matching criteria array. + * + * @param array $criteria + * @return array|Paginator + */ + abstract public function getBy(array $criteria); + + /** + * Return one entity matching criteria array. + * + * @param array $criteria + * + * @return mixed + */ + abstract public function getOneBy(array $criteria); + + /** + * Count entities matching criteria array. + * + * @param array $criteria + * + * @return int + */ + abstract public function countBy(array $criteria); +} diff --git a/lib/RoadizCoreBundle/src/EntityApi/NodeApi.php b/lib/RoadizCoreBundle/src/EntityApi/NodeApi.php new file mode 100644 index 00000000..2024bf32 --- /dev/null +++ b/lib/RoadizCoreBundle/src/EntityApi/NodeApi.php @@ -0,0 +1,84 @@ +managerRegistry + ->getRepository(Node::class) + ->setDisplayingNotPublishedNodes(false) + ->setDisplayingAllNodesStatuses(false); + return $repository; + } + + /** + * @param array $criteria + * @param array|null $order + * @param int|null $limit + * @param int|null $offset + * @return array|Paginator + */ + public function getBy( + array $criteria, + array $order = null, + ?int $limit = null, + ?int $offset = null + ) { + if (!in_array('translation.available', $criteria, true)) { + $criteria['translation.available'] = true; + } + + return $this->getRepository() + ->findBy( + $criteria, + $order, + $limit, + $offset, + null + ); + } + /** + * {@inheritdoc} + */ + public function countBy(array $criteria) + { + if (!in_array('translation.available', $criteria, true)) { + $criteria['translation.available'] = true; + } + + return $this->getRepository() + ->countBy( + $criteria, + null + ); + } + /** + * {@inheritdoc} + */ + public function getOneBy(array $criteria, array $order = null) + { + if (!in_array('translation.available', $criteria, true)) { + $criteria['translation.available'] = true; + } + + return $this->getRepository() + ->findOneBy( + $criteria, + $order, + null + ); + } +} diff --git a/lib/RoadizCoreBundle/src/EntityApi/NodeSourceApi.php b/lib/RoadizCoreBundle/src/EntityApi/NodeSourceApi.php new file mode 100644 index 00000000..e3dc5a8f --- /dev/null +++ b/lib/RoadizCoreBundle/src/EntityApi/NodeSourceApi.php @@ -0,0 +1,141 @@ +repository = $criteria['node.nodeType']->getSourceEntityFullQualifiedClassName(); + unset($criteria['node.nodeType']); + } elseif ( + isset($criteria['node.nodeType']) && + is_array($criteria['node.nodeType']) && + count($criteria['node.nodeType']) === 1 && + $criteria['node.nodeType'][0] instanceof NodeType + ) { + $this->repository = $criteria['node.nodeType'][0]->getSourceEntityFullQualifiedClassName(); + unset($criteria['node.nodeType']); + } else { + $this->repository = NodesSources::class; + } + + return $this->repository; + } + + /** + * @return NodesSourcesRepository|EntityRepository + */ + public function getRepository() + { + return $this->managerRegistry->getRepository($this->repository); + } + + /** + * @param array $criteria + * @param array|null $order + * @param int|null $limit + * @param int|null $offset + * @return array|Paginator + */ + public function getBy( + array $criteria, + array $order = null, + ?int $limit = null, + ?int $offset = null + ) { + $this->getRepositoryName($criteria); + + return $this->getRepository() + ->findBy( + $criteria, + $order, + $limit, + $offset + ); + } + + /** + * @param array $criteria + * @return int + * @throws \Doctrine\ORM\NoResultException + * @throws \Doctrine\ORM\NonUniqueResultException + */ + public function countBy(array $criteria) + { + $this->getRepositoryName($criteria); + + return $this->getRepository() + ->countBy( + $criteria + ); + } + + /** + * @param array $criteria + * @param array|null $order + * @return null|NodesSources + */ + public function getOneBy(array $criteria, array $order = null) + { + $this->getRepositoryName($criteria); + + return $this->getRepository() + ->findOneBy( + $criteria, + $order + ); + } + + /** + * Search Nodes-Sources using LIKE condition on title, + * meta-title, meta-keywords and meta-description. + * + * @param string $textQuery + * @param int $limit + * @param array $nodeTypes + * @param bool $onlyVisible + * @param array $additionalCriteria + * @return array + */ + public function searchBy( + string $textQuery, + int $limit = 0, + array $nodeTypes = [], + bool $onlyVisible = false, + array $additionalCriteria = [] + ) { + $repository = $this->getRepository(); + + if ($repository instanceof NodesSourcesRepository) { + return $this->getRepository() + ->findByTextQuery( + $textQuery, + $limit, + $nodeTypes, + $onlyVisible, + $additionalCriteria + ); + } + + return []; + } +} diff --git a/lib/RoadizCoreBundle/src/EntityApi/NodeTypeApi.php b/lib/RoadizCoreBundle/src/EntityApi/NodeTypeApi.php new file mode 100644 index 00000000..c3aecd2a --- /dev/null +++ b/lib/RoadizCoreBundle/src/EntityApi/NodeTypeApi.php @@ -0,0 +1,40 @@ +managerRegistry->getRepository(NodeType::class); + } + /** + * {@inheritdoc} + */ + public function getBy(array $criteria, array $order = null) + { + return $this->getRepository()->findBy($criteria, $order); + } + /** + * {@inheritdoc} + */ + public function getOneBy(array $criteria, array $order = null) + { + return $this->getRepository()->findOneBy($criteria, $order); + } + /** + * {@inheritdoc} + */ + public function countBy(array $criteria) + { + return $this->getRepository()->countBy($criteria); + } +} diff --git a/lib/RoadizCoreBundle/src/EntityApi/TagApi.php b/lib/RoadizCoreBundle/src/EntityApi/TagApi.php new file mode 100644 index 00000000..3b363371 --- /dev/null +++ b/lib/RoadizCoreBundle/src/EntityApi/TagApi.php @@ -0,0 +1,77 @@ +managerRegistry->getRepository(Tag::class); + } + + /** + * Get tags using criteria, orders, limit and offset. + * + * When no order is defined, tags are ordered by position. + * + * @param array $criteria + * @param array|null $order + * @param int|null $limit + * @param int|null $offset + * + * @return array|Paginator + */ + public function getBy( + array $criteria, + array $order = null, + ?int $limit = null, + ?int $offset = null + ) { + if (null === $order) { + $order = [ + 'position' => 'ASC', + ]; + } + + return $this->getRepository() + ->findBy( + $criteria, + $order, + $limit, + $offset, + null + ); + } + /** + * {@inheritdoc} + */ + public function countBy(array $criteria) + { + return $this->getRepository() + ->countBy( + $criteria, + null + ); + } + /** + * {@inheritdoc} + */ + public function getOneBy(array $criteria, array $order = null) + { + return $this->getRepository() + ->findOneBy( + $criteria, + $order, + null + ); + } +} diff --git a/lib/RoadizCoreBundle/src/EntityHandler/CustomFormFieldHandler.php b/lib/RoadizCoreBundle/src/EntityHandler/CustomFormFieldHandler.php new file mode 100644 index 00000000..368bb8fd --- /dev/null +++ b/lib/RoadizCoreBundle/src/EntityHandler/CustomFormFieldHandler.php @@ -0,0 +1,67 @@ +customFormField; + } + /** + * @param CustomFormField $customFormField + * @return $this + */ + public function setCustomFormField(CustomFormField $customFormField) + { + $this->customFormField = $customFormField; + return $this; + } + + /** + * Create a new custom-form-field handler with custom-form-field to handle. + * + * @param ObjectManager $objectManager + * @param CustomFormHandler $customFormHandler + */ + public function __construct(ObjectManager $objectManager, CustomFormHandler $customFormHandler) + { + parent::__construct($objectManager); + $this->customFormHandler = $customFormHandler; + } + + /** + * Clean position for current customForm siblings. + * + * @param bool $setPositions + * @return float Return the next position after the **last** customFormField + */ + public function cleanPositions(bool $setPositions = true): float + { + if (null === $this->customFormField) { + throw new \BadMethodCallException('CustomForm is null'); + } + + if ($this->customFormField->getCustomForm() !== null) { + $this->customFormHandler->setCustomForm($this->customFormField->getCustomForm()); + return $this->customFormHandler->cleanFieldsPositions($setPositions); + } + + return 1; + } +} diff --git a/lib/RoadizCoreBundle/src/EntityHandler/CustomFormHandler.php b/lib/RoadizCoreBundle/src/EntityHandler/CustomFormHandler.php new file mode 100644 index 00000000..d77dd59e --- /dev/null +++ b/lib/RoadizCoreBundle/src/EntityHandler/CustomFormHandler.php @@ -0,0 +1,62 @@ +customForm; + } + + /** + * @param CustomForm $customForm + * @return $this + */ + public function setCustomForm(CustomForm $customForm) + { + $this->customForm = $customForm; + return $this; + } + + /** + * Reset current node-type fields positions. + * + * @param bool $setPositions + * @return float Return the next position after the **last** field + */ + public function cleanFieldsPositions(bool $setPositions = true): float + { + if (null === $this->customForm) { + throw new \BadMethodCallException('CustomForm is null'); + } + + $criteria = Criteria::create(); + $criteria->orderBy(['position' => 'ASC']); + $fields = $this->customForm->getFields()->matching($criteria); + $i = 1; + foreach ($fields as $field) { + if ($setPositions) { + $field->setPosition($i); + } + $i++; + } + + if ($setPositions) { + $this->objectManager->flush(); + } + + return $i; + } +} diff --git a/lib/RoadizCoreBundle/src/EntityHandler/DocumentHandler.php b/lib/RoadizCoreBundle/src/EntityHandler/DocumentHandler.php new file mode 100644 index 00000000..96b4bdbb --- /dev/null +++ b/lib/RoadizCoreBundle/src/EntityHandler/DocumentHandler.php @@ -0,0 +1,101 @@ +documentStorage = $documentStorage; + } + + /** + * Get a Response object to force download document. + * This method works for both private and public documents. + * + * @return StreamedResponse + * @throws FilesystemException + */ + public function getDownloadResponse(): StreamedResponse + { + if ($this->document->isLocal()) { + $documentPath = $this->document->getMountPath(); + + if ($this->documentStorage->fileExists($documentPath)) { + return new StreamedResponse(function () use ($documentPath) { + \fpassthru($this->documentStorage->readStream($documentPath)); + }, Response::HTTP_OK, [ + "Content-Type" => $this->documentStorage->mimeType($documentPath), + "Content-Length" => $this->documentStorage->fileSize($documentPath), + "Content-disposition" => "attachment; filename=\"" . basename($this->document->getFilename()) . "\"", + ]); + } + } + + throw new ResourceNotFoundException(); + } + + /** + * Return documents folders with the same translation as + * current document. + * + * @param Translation|null $translation + * @return array + */ + public function getFolders(Translation $translation = null): array + { + if (!$this->document instanceof Document) { + return []; + } + /** @var FolderRepository $repository */ + $repository = $this->objectManager->getRepository(Folder::class); + if (null !== $translation) { + return $repository->findByDocumentAndTranslation($this->document, $translation); + } + + $docTranslation = $this->document->getDocumentTranslations()->first(); + if ($docTranslation instanceof DocumentTranslation) { + return $repository->findByDocumentAndTranslation($this->document, $docTranslation->getTranslation()); + } + + return $repository->findByDocumentAndTranslation($this->document); + } + + public function getDocument(): ?DocumentInterface + { + return $this->document; + } + + /** + * @param DocumentInterface $document + * @return DocumentHandler + */ + public function setDocument(DocumentInterface $document): DocumentHandler + { + $this->document = $document; + return $this; + } +} diff --git a/lib/RoadizCoreBundle/src/EntityHandler/FolderHandler.php b/lib/RoadizCoreBundle/src/EntityHandler/FolderHandler.php new file mode 100644 index 00000000..2b01d6af --- /dev/null +++ b/lib/RoadizCoreBundle/src/EntityHandler/FolderHandler.php @@ -0,0 +1,190 @@ +folder) { + throw new \BadMethodCallException('Folder is null'); + } + return $this->folder; + } + + /** + * @param Folder $folder + * @return $this + */ + public function setFolder(Folder $folder) + { + $this->folder = $folder; + return $this; + } + + /** + * Remove only current folder children. + * + * @return $this + */ + private function removeChildren() + { + /** @var Folder $folder */ + foreach ($this->getFolder()->getChildren() as $folder) { + $handler = new FolderHandler($this->objectManager); + $handler->setFolder($folder); + $handler->removeWithChildrenAndAssociations(); + } + + return $this; + } + + /** + * Remove current folder with its children recursively and + * its associations. + * + * @return $this + */ + public function removeWithChildrenAndAssociations() + { + $this->removeChildren(); + $this->objectManager->remove($this->getFolder()); + + /* + * Final flush + */ + $this->objectManager->flush(); + return $this; + } + + /** + * Return every folder’s parents. + * + * @deprecated Use directly Folder::getParents method. + * @return array + */ + public function getParents(): array + { + $parentsArray = []; + $parent = $this->getFolder(); + + do { + $parent = $parent->getParent(); + if ($parent !== null) { + $parentsArray[] = $parent; + } else { + break; + } + } while ($parent !== null); + + return array_reverse($parentsArray); + } + + /** + * Get folder full path using folder names. + * + * @deprecated Use directly Folder::getFullPath method. + * @return string + */ + public function getFullPath(): string + { + $parents = $this->getParents(); + $path = []; + + foreach ($parents as $parent) { + $path[] = $parent->getFolderName(); + } + + $path[] = $this->getFolder()->getFolderName(); + + return implode('/', $path); + } + + /** + * Clean position for current folder siblings. + * + * @param bool $setPositions + * @return float Return the next position after the **last** folder + */ + public function cleanPositions(bool $setPositions = true): float + { + if ($this->getFolder()->getParent() !== null) { + $parentHandler = new FolderHandler($this->objectManager); + /** @var Folder|null $parent */ + $parent = $this->getFolder()->getParent(); + $parentHandler->setFolder($parent); + return $parentHandler->cleanChildrenPositions($setPositions); + } else { + return $this->cleanRootFoldersPositions($setPositions); + } + } + + /** + * Reset current folder children positions. + * + * Warning, this method does not flush. + * + * @param bool $setPositions + * @return float Return the next position after the **last** folder + */ + public function cleanChildrenPositions(bool $setPositions = true): float + { + /* + * Force collection to sort on position + */ + $sort = Criteria::create(); + $sort->orderBy([ + 'position' => Criteria::ASC + ]); + + $children = $this->getFolder()->getChildren()->matching($sort); + $i = 1; + /** @var Folder $child */ + foreach ($children as $child) { + if ($setPositions) { + $child->setPosition($i); + } + $i++; + } + + return $i; + } + + /** + * Reset every root folders positions. + * + * Warning, this method does not flush. + * + * @param bool $setPositions + * @return float Return the next position after the **last** folder + */ + public function cleanRootFoldersPositions(bool $setPositions = true): float + { + /** @var Folder[] $folders */ + $folders = $this->objectManager + ->getRepository(Folder::class) + ->findBy(['parent' => null], ['position' => 'ASC']); + + $i = 1; + foreach ($folders as $child) { + if ($setPositions) { + $child->setPosition($i); + } + $i++; + } + + return $i; + } +} diff --git a/lib/RoadizCoreBundle/src/EntityHandler/GroupHandler.php b/lib/RoadizCoreBundle/src/EntityHandler/GroupHandler.php new file mode 100644 index 00000000..b4cfe577 --- /dev/null +++ b/lib/RoadizCoreBundle/src/EntityHandler/GroupHandler.php @@ -0,0 +1,58 @@ +group) { + throw new \BadMethodCallException('Group is null'); + } + return $this->group; + } + + /** + * @param Group $group + * @return $this + */ + public function setGroup(Group $group): self + { + $this->group = $group; + return $this; + } + + /** + * This method does not flush ORM. You'll need to manually call it. + * + * @param Group $newGroup + */ + public function diff(Group $newGroup): void + { + if ("" != $newGroup->getName()) { + $this->getGroup()->setName($newGroup->getName()); + } + + $existingRolesNames = $this->getGroup()->getRoles(); + + foreach ($newGroup->getRolesEntities() as $newRole) { + if (false === in_array($newRole->getName(), $existingRolesNames)) { + /** @var Role|null $role */ + $role = $this->objectManager->getRepository(Role::class) + ->findOneByName($newRole->getName()); + $this->getGroup()->addRoleEntity($role); + } + } + } +} diff --git a/lib/RoadizCoreBundle/src/EntityHandler/HandlerFactory.php b/lib/RoadizCoreBundle/src/EntityHandler/HandlerFactory.php new file mode 100644 index 00000000..8975ba83 --- /dev/null +++ b/lib/RoadizCoreBundle/src/EntityHandler/HandlerFactory.php @@ -0,0 +1,70 @@ +container = $container; + } + + /** + * @param AbstractEntity $entity + * @return AbstractHandler + * @throws \Psr\Container\ContainerExceptionInterface + * @throws \Psr\Container\NotFoundExceptionInterface + */ + public function getHandler(AbstractEntity $entity): AbstractHandler + { + switch (true) { + case ($entity instanceof Node): + return $this->container->get(NodeHandler::class)->setNode($entity); + case ($entity instanceof NodesSources): + return $this->container->get(NodesSourcesHandler::class)->setNodeSource($entity); + case ($entity instanceof NodeType): + return $this->container->get(NodeTypeHandler::class)->setNodeType($entity); + case ($entity instanceof NodeTypeField): + return $this->container->get(NodeTypeFieldHandler::class)->setNodeTypeField($entity); + case ($entity instanceof Document): + return $this->container->get(DocumentHandler::class)->setDocument($entity); + case ($entity instanceof CustomForm): + return $this->container->get(CustomFormHandler::class)->setCustomForm($entity); + case ($entity instanceof CustomFormField): + return $this->container->get(CustomFormFieldHandler::class)->setCustomFormField($entity); + case ($entity instanceof Folder): + return $this->container->get(FolderHandler::class)->setFolder($entity); + case ($entity instanceof Group): + return $this->container->get(GroupHandler::class)->setGroup($entity); + case ($entity instanceof Tag): + return $this->container->get(TagHandler::class)->setTag($entity); + case ($entity instanceof Translation): + return $this->container->get(TranslationHandler::class)->setTranslation($entity); + } + + throw new \InvalidArgumentException('HandlerFactory does not support ' . get_class($entity)); + } +} diff --git a/lib/RoadizCoreBundle/src/EntityHandler/NodeHandler.php b/lib/RoadizCoreBundle/src/EntityHandler/NodeHandler.php new file mode 100644 index 00000000..d9a9b408 --- /dev/null +++ b/lib/RoadizCoreBundle/src/EntityHandler/NodeHandler.php @@ -0,0 +1,682 @@ +registry = $registry; + $this->chrootResolver = $chrootResolver; + $this->nodeNamePolicy = $nodeNamePolicy; + } + + protected function createSelf(): self + { + return new static( + $this->objectManager, + $this->registry, + $this->chrootResolver, + $this->nodeNamePolicy + ); + } + + /** + * @return Node + */ + public function getNode(): Node + { + if (null === $this->node) { + throw new \BadMethodCallException('Node is null'); + } + return $this->node; + } + + /** + * @param Node $node + * @return NodeHandler + */ + public function setNode(Node $node) + { + $this->node = $node; + return $this; + } + + /** + * Remove every node to custom-forms associations for a given field. + * + * @param NodeTypeField $field + * @param bool $flush + * @return $this + */ + public function cleanCustomFormsFromField(NodeTypeField $field, bool $flush = true) + { + $nodesCustomForms = $this->objectManager + ->getRepository(NodesCustomForms::class) + ->findBy(['node' => $this->getNode(), 'field' => $field]); + + foreach ($nodesCustomForms as $ncf) { + $this->objectManager->remove($ncf); + } + + if (true === $flush) { + $this->objectManager->flush(); + } + + return $this; + } + + /** + * Add a node to current custom-forms for a given node-type field. + * + * @param CustomForm $customForm + * @param NodeTypeField $field + * @param bool $flush + * @param null|float $position + * @return $this + */ + public function addCustomFormForField( + CustomForm $customForm, + NodeTypeField $field, + bool $flush = true, + ?float $position = null + ) { + $ncf = new NodesCustomForms($this->getNode(), $customForm, $field); + + if (null === $position) { + $latestPosition = $this->objectManager + ->getRepository(NodesCustomForms::class) + ->getLatestPosition($this->getNode(), $field); + $ncf->setPosition($latestPosition + 1); + } else { + $ncf->setPosition($position); + } + + $this->objectManager->persist($ncf); + + if (true === $flush) { + $this->objectManager->flush(); + } + + return $this; + } + + /** + * Get custom forms linked to current node for a given field name. + * + * @param string $fieldName Name of the node-type field + * @return array + */ + public function getCustomFormsFromFieldName(string $fieldName): array + { + return $this->objectManager + ->getRepository(CustomForm::class) + ->findByNodeAndField( + $this->getNode(), + $this->getNode()->getNodeType()->getFieldByName($fieldName) + ); + } + + /** + * Remove every node to node associations for a given field. + * + * @param NodeTypeField $field + * @param bool $flush + * @return $this + */ + public function cleanNodesFromField(NodeTypeField $field, bool $flush = true) + { + $this->node->clearBNodesForField($field); + + if (true === $flush) { + $this->objectManager->flush(); + } + + return $this; + } + + /** + * Add a node to current node for a given node-type field. + * + * @param Node $node + * @param NodeTypeField $field + * @param bool $flush + * @param null|float $position + * @return $this + */ + public function addNodeForField(Node $node, NodeTypeField $field, bool $flush = true, ?float $position = null) + { + $ntn = new NodesToNodes($this->getNode(), $node, $field); + + if (!$this->node->hasBNode($ntn)) { + if (null === $position) { + $latestPosition = $this->objectManager + ->getRepository(NodesToNodes::class) + ->getLatestPosition($this->getNode(), $field); + $ntn->setPosition($latestPosition + 1); + } else { + $ntn->setPosition($position); + } + $this->node->addBNode($ntn); + $this->objectManager->persist($ntn); + if (true === $flush) { + $this->objectManager->flush(); + } + } + + return $this; + } + + /** + * Get nodes linked to current node for a given field name. + * + * @param string $fieldName Name of the node-type field + * @return Node[] + */ + public function getNodesFromFieldName(string $fieldName): array + { + $field = $this->getNode()->getNodeType()->getFieldByName($fieldName); + if (null !== $field) { + return $this->getRepository() + ->findByNodeAndField( + $this->getNode(), + $field + ); + } + return []; + } + + /** + * Get nodes reversed-linked to current node for a given field name. + * + * @param string $fieldName Name of the node-type field + * @return Node[] + */ + public function getReverseNodesFromFieldName(string $fieldName): array + { + $field = $this->getNode()->getNodeType()->getFieldByName($fieldName); + if (null !== $field) { + return $this->getRepository() + ->findByReverseNodeAndField( + $this->getNode(), + $field + ); + } + return []; + } + + /** + * Get node source by translation. + * + * @param Translation $translation + * + * @return null|NodesSources + */ + public function getNodeSourceByTranslation($translation): ?NodesSources + { + return $this->objectManager + ->getRepository(NodesSources::class) + ->findOneBy(["node" => $this->getNode(), "translation" => $translation]); + } + + /** + * Remove only current node children. + * + * @return $this + */ + private function removeChildren() + { + /** @var Node $node */ + foreach ($this->getNode()->getChildren() as $node) { + $handler = $this->createSelf(); + $handler->setNode($node); + $handler->removeWithChildrenAndAssociations(); + } + + return $this; + } + /** + * Remove only current node associations. + * + * @return $this + */ + public function removeAssociations() + { + /** @var NodesSources $ns */ + foreach ($this->getNode()->getNodeSources() as $ns) { + $this->objectManager->remove($ns); + } + + return $this; + } + /** + * Remove current node with its children recursively and + * its associations. + * + * This method DOES NOT flush objectManager + * + * @return $this + */ + public function removeWithChildrenAndAssociations() + { + $this->removeChildren(); + $this->removeAssociations(); + $this->objectManager->remove($this->getNode()); + + return $this; + } + + /** + * @return Workflow + */ + private function getWorkflow(): Workflow + { + return $this->registry->get($this->getNode()); + } + + /** + * Soft delete node and its children. + * + * **This method does not flush!** + * + * @return $this + */ + public function softRemoveWithChildren() + { + $workflow = $this->getWorkflow(); + if ($workflow->can($this->getNode(), 'delete')) { + $workflow->apply($this->getNode(), 'delete'); + } + + /** @var Node $node */ + foreach ($this->getNode()->getChildren() as $node) { + $handler = $this->createSelf(); + $handler->setNode($node); + $handler->softRemoveWithChildren(); + } + + return $this; + } + + /** + * Un-delete node and its children. + * + * **This method does not flush!** + * + * @return $this + */ + public function softUnremoveWithChildren() + { + $workflow = $this->getWorkflow(); + if ($workflow->can($this->getNode(), 'undelete')) { + $workflow->apply($this->getNode(), 'undelete'); + } + + /** @var Node $node */ + foreach ($this->getNode()->getChildren() as $node) { + $handler = $this->createSelf(); + $handler->setNode($node); + $handler->softUnremoveWithChildren(); + } + + return $this; + } + + /** + * Publish node and its children. + * + * **This method does not flush!** + * + * @return $this + */ + public function publishWithChildren() + { + $workflow = $this->getWorkflow(); + if ($workflow->can($this->getNode(), 'publish')) { + $workflow->apply($this->getNode(), 'publish'); + } + + /** @var Node $node */ + foreach ($this->getNode()->getChildren() as $node) { + $handler = $this->createSelf(); + $handler->setNode($node); + $handler->publishWithChildren(); + } + return $this; + } + + /** + * Archive node and its children. + * + * **This method does not flush!** + * + * @return $this + */ + public function archiveWithChildren() + { + $workflow = $this->getWorkflow(); + if ($workflow->can($this->getNode(), 'archive')) { + $workflow->apply($this->getNode(), 'archive'); + } + + /** @var Node $node */ + foreach ($this->getNode()->getChildren() as $node) { + $handler = $this->createSelf(); + $handler->setNode($node); + $handler->archiveWithChildren(); + } + + return $this; + } + + /** + * Return if is in Newsletter Node. + * + * @deprecated Just here not to break themes. + * @return bool + */ + public function isRelatedToNewsletter(): bool + { + return false; + } + + /** + * Return if part of Node offspring. + * + * @param Node $relative + * + * @return bool + */ + public function isRelatedToNode(Node $relative): bool + { + if ($this->getNode()->getId() === $relative->getId()) { + return true; + } + + $parents = $this->getParents(); + foreach ($parents as $parent) { + if ($parent->getId() === $relative->getId()) { + return true; + } + } + return false; + } + + /** + * Return every node’s parents + * + * @param TokenStorageInterface|null $tokenStorage + * @return array + */ + public function getParents(?TokenStorageInterface $tokenStorage = null): array + { + $parentsArray = []; + $parent = $this->getNode()->getParent(); + $chroot = null; + + if ($tokenStorage !== null) { + $user = $tokenStorage->getToken()->getUser(); + /** @var Node|null $chroot */ + $chroot = $this->chrootResolver->getChroot($user); + } + + while ($parent !== null && $parent !== $chroot) { + $parentsArray[] = $parent; + $parent = $parent->getParent(); + } + + return array_reverse($parentsArray); + } + + /** + * Clean position for current node siblings. + * + * Warning, this method does not flush. + * + * @param bool $setPositions + * @return float Return the next position after the **last** node + */ + public function cleanPositions(bool $setPositions = true): float + { + if ($this->getNode()->getParent() !== null) { + $parentHandler = $this->createSelf(); + /** @var Node|null $parent */ + $parent = $this->getNode()->getParent(); + $parentHandler->setNode($parent); + return $parentHandler->cleanChildrenPositions($setPositions); + } else { + return $this->cleanRootNodesPositions($setPositions); + } + } + + /** + * Reset current node children positions. + * + * Warning, this method does not flush. + * + * @param bool $setPositions + * @return float Return the next position after the **last** node + */ + public function cleanChildrenPositions(bool $setPositions = true): float + { + /* + * Force collection to sort on position + */ + $sort = Criteria::create(); + $sort->orderBy([ + 'position' => Criteria::ASC + ]); + + $children = $this->getNode()->getChildren()->matching($sort); + $i = 1; + /** @var Node $child */ + foreach ($children as $child) { + if ($setPositions) { + $child->setPosition($i); + } + $i++; + } + + return $i; + } + + /** + * Reset every root nodes positions. + * + * Warning, this method does not flush. + * + * @param bool $setPositions + * @return float Return the next position after the **last** node + */ + public function cleanRootNodesPositions(bool $setPositions = true): float + { + $nodes = $this->getRepository() + ->setDisplayingNotPublishedNodes(true) + ->findBy(['parent' => null], ['position' => 'ASC']); + + $i = 1; + /** @var Node $child */ + foreach ($nodes as $child) { + if ($setPositions) { + $child->setPosition($i); + } + $i++; + } + + return $i; + } + + /** + * Return all node offspring id. + * + * @return array + */ + public function getAllOffspringId(): array + { + return $this->getRepository()->findAllOffspringIdByNode($this->getNode()); + } + + /** + * Set current node as the Home node. + * + * @return $this + */ + public function makeHome() + { + $defaults = $this->getRepository() + ->setDisplayingNotPublishedNodes(true) + ->findBy(['home' => true]); + + /** @var Node $default */ + foreach ($defaults as $default) { + $default->setHome(false); + } + $this->getNode()->setHome(true); + $this->objectManager->flush(); + + return $this; + } + + /** + * Duplicate current node with all its children. + * + * @return Node + * @deprecated Use NodeDuplicator::duplicate() instead. + */ + public function duplicate() + { + $duplicator = new NodeDuplicator( + $this->getNode(), + $this->objectManager, + $this->nodeNamePolicy + ); + return $duplicator->duplicate(); + } + + /** + * Get previous node from hierarchy. + * + * @param array|null $criteria + * @param array|null $order + * + * @return Node|null + */ + public function getPrevious( + ?array $criteria = null, + ?array $order = null + ) { + if ($this->getNode()->getPosition() <= 1) { + return null; + } + if (null === $order) { + $order = []; + } + + if (null === $criteria) { + $criteria = []; + } + + $criteria['parent'] = $this->getNode()->getParent(); + /* + * Use < operator to get first previous nodeSource + * even if it’s not the previous position index + */ + $criteria['position'] = [ + '<', + $this->getNode()->getPosition(), + ]; + + $order['position'] = 'DESC'; + + return $this->getRepository()->findOneBy( + $criteria, + $order + ); + } + + /** + * Get next node from hierarchy. + * + * @param array|null $criteria + * @param array|null $order + * + * @return Node|null + */ + public function getNext( + ?array $criteria = null, + ?array $order = null + ) { + if (null === $criteria) { + $criteria = []; + } + if (null === $order) { + $order = []; + } + + $criteria['parent'] = $this->getNode()->getParent(); + + /* + * Use > operator to get first next nodeSource + * even if it’s not the next position index + */ + $criteria['position'] = [ + '>', + $this->getNode()->getPosition(), + ]; + $order['position'] = 'ASC'; + + return $this->getRepository() + ->findOneBy( + $criteria, + $order + ); + } + + /** + * @return NodeRepository + */ + public function getRepository(): NodeRepository + { + return $this->objectManager->getRepository(Node::class); + } +} diff --git a/lib/RoadizCoreBundle/src/EntityHandler/NodeTypeFieldHandler.php b/lib/RoadizCoreBundle/src/EntityHandler/NodeTypeFieldHandler.php new file mode 100644 index 00000000..a1542bb8 --- /dev/null +++ b/lib/RoadizCoreBundle/src/EntityHandler/NodeTypeFieldHandler.php @@ -0,0 +1,68 @@ +nodeTypeField) { + throw new \BadMethodCallException('NodeTypeField is null'); + } + return $this->nodeTypeField; + } + + /** + * @param NodeTypeField $nodeTypeField + * @return $this + */ + public function setNodeTypeField(NodeTypeField $nodeTypeField): self + { + $this->nodeTypeField = $nodeTypeField; + return $this; + } + + /** + * Create a new node-type-field handler with node-type-field to handle. + * + * @param ObjectManager $objectManager + * @param HandlerFactory $handlerFactory + */ + public function __construct(ObjectManager $objectManager, HandlerFactory $handlerFactory) + { + parent::__construct($objectManager); + $this->handlerFactory = $handlerFactory; + } + + /** + * Clean position for current node siblings. + * + * @param bool $setPositions + * @return float Return the next position after the **last** node + * @throws \Psr\Container\ContainerExceptionInterface + * @throws \Psr\Container\NotFoundExceptionInterface + */ + public function cleanPositions(bool $setPositions = false): float + { + if ($this->nodeTypeField->getNodeType() instanceof NodeType) { + /** @var NodeTypeHandler $nodeTypeHandler */ + $nodeTypeHandler = $this->handlerFactory->getHandler($this->nodeTypeField->getNodeType()); + return $nodeTypeHandler->cleanPositions(); + } + + return 1; + } +} diff --git a/lib/RoadizCoreBundle/src/EntityHandler/NodeTypeHandler.php b/lib/RoadizCoreBundle/src/EntityHandler/NodeTypeHandler.php new file mode 100644 index 00000000..8c6c7a39 --- /dev/null +++ b/lib/RoadizCoreBundle/src/EntityHandler/NodeTypeHandler.php @@ -0,0 +1,380 @@ +nodeType) { + throw new \BadMethodCallException('NodeType is null'); + } + return $this->nodeType; + } + + /** + * @param NodeType $nodeType + * @return $this + */ + public function setNodeType(NodeType $nodeType) + { + $this->nodeType = $nodeType; + return $this; + } + + public function __construct( + ObjectManager $objectManager, + EntityGeneratorFactory $entityGeneratorFactory, + HandlerFactory $handlerFactory, + SerializerInterface $serializer, + ApiResourceGenerator $apiResourceGenerator, + string $generatedEntitiesDir, + string $serializedNodeTypesDir, + string $importFilesConfigPath, + string $kernelProjectDir + ) { + parent::__construct($objectManager); + $this->entityGeneratorFactory = $entityGeneratorFactory; + $this->handlerFactory = $handlerFactory; + $this->generatedEntitiesDir = $generatedEntitiesDir; + $this->serializer = $serializer; + $this->serializedNodeTypesDir = $serializedNodeTypesDir; + $this->importFilesConfigPath = $importFilesConfigPath; + $this->kernelProjectDir = $kernelProjectDir; + $this->apiResourceGenerator = $apiResourceGenerator; + } + + public function getGeneratedEntitiesFolder(): string + { + return $this->generatedEntitiesDir; + } + + public function getGeneratedRepositoriesFolder(): string + { + return $this->getGeneratedEntitiesFolder() . DIRECTORY_SEPARATOR . 'Repository'; + } + + /** + * Remove node type entity class file from server. + */ + public function removeSourceEntityClass(): bool + { + $file = $this->getSourceClassPath(); + $repositoryFile = $this->getRepositoryClassPath(); + $fileSystem = new Filesystem(); + + if ($fileSystem->exists($file) && is_file($file)) { + $fileSystem->remove($file); + /* + * Delete repository class file too. + */ + if ($fileSystem->exists($repositoryFile) && is_file($repositoryFile)) { + $fileSystem->remove($repositoryFile); + } + return true; + } + + return false; + } + + public function exportNodeTypeJsonFile(): ?string + { + $fileSystem = new Filesystem(); + if ($fileSystem->exists($this->serializedNodeTypesDir)) { + $content = $this->serializer->serialize( + $this->nodeType, + 'json', + SerializationContext::create()->setGroups(['node_type', 'position']) + ); + $file = $this->serializedNodeTypesDir . DIRECTORY_SEPARATOR . $this->nodeType->getName() . '.json'; + @file_put_contents($file, $content); + + $this->addNodeTypeToImportFilesConfiguration($fileSystem, $file); + + return $file; + } + return null; + } + + protected function removeNodeTypeJsonFile(): void + { + $fileSystem = new Filesystem(); + $file = $this->serializedNodeTypesDir . DIRECTORY_SEPARATOR . $this->nodeType->getName() . '.json'; + if ($fileSystem->exists($file)) { + @unlink($file); + $this->removeNodeTypeFromImportFilesConfiguration($fileSystem, $file); + } + } + + protected function addNodeTypeToImportFilesConfiguration(Filesystem $fileSystem, string $file): void + { + if ($fileSystem->exists($this->importFilesConfigPath)) { + $configFile = new File($this->importFilesConfigPath); + if ($configFile->isWritable()) { + try { + $config = Yaml::parseFile($this->importFilesConfigPath); + if (!isset($config['importFiles'])) { + $config['importFiles'] = [ + 'nodetypes' => [] + ]; + } + if (!isset($config['importFiles']['nodetypes'])) { + $config['importFiles']['nodetypes'] = []; + } + + $relativePath = str_replace( + $this->kernelProjectDir . DIRECTORY_SEPARATOR, + '', + $file + ); + if (!in_array($relativePath, $config['importFiles']['nodetypes'])) { + $config['importFiles']['nodetypes'][] = $relativePath; + sort($config['importFiles']['nodetypes']); + + $yamlContent = Yaml::dump($config, 3); + @file_put_contents($this->importFilesConfigPath, $yamlContent); + } + } catch (ParseException $exception) { + // Silent errors + } + } + } + } + + protected function removeNodeTypeFromImportFilesConfiguration(Filesystem $fileSystem, string $file): void + { + if ($fileSystem->exists($this->importFilesConfigPath)) { + $configFile = new File($this->importFilesConfigPath); + if ($configFile->isWritable()) { + try { + $config = Yaml::parseFile($this->importFilesConfigPath); + if (!isset($config['importFiles'])) { + return; + } + if (!isset($config['importFiles']['nodetypes'])) { + return; + } + + $relativePath = str_replace( + $this->kernelProjectDir . DIRECTORY_SEPARATOR, + '', + $file + ); + if (false !== $key = array_search($relativePath, $config['importFiles']['nodetypes'])) { + unset($config['importFiles']['nodetypes'][$key]); + $config['importFiles']['nodetypes'] = array_values(array_filter($config['importFiles']['nodetypes'])); + sort($config['importFiles']['nodetypes']); + $yamlContent = Yaml::dump($config, 3); + @file_put_contents($this->importFilesConfigPath, $yamlContent); + } + } catch (ParseException $exception) { + // Silent errors + } + } + } + } + + /** + * Generate Doctrine entity class for current node-type. + * + * @return bool + */ + public function generateSourceEntityClass(): bool + { + $folder = $this->getGeneratedEntitiesFolder(); + $repositoryFolder = $this->getGeneratedRepositoriesFolder(); + $file = $this->getSourceClassPath(); + $repositoryFile = $this->getRepositoryClassPath(); + $fileSystem = new Filesystem(); + + if (!$fileSystem->exists($folder)) { + $fileSystem->mkdir($folder, 0775); + } + if (!$fileSystem->exists($repositoryFolder)) { + $fileSystem->mkdir($repositoryFolder, 0775); + } + + if (!$fileSystem->exists($file)) { + $classGenerator = $this->entityGeneratorFactory->createWithCustomRepository($this->nodeType); + $repositoryGenerator = $this->entityGeneratorFactory->createCustomRepository($this->nodeType); + $content = $classGenerator->getClassContent(); + $repositoryContent = $repositoryGenerator->getClassContent(); + + if (false === @file_put_contents($file, $content)) { + throw new IOException("Impossible to write entity class file (" . $file . ").", 1); + } + if (false === @file_put_contents($repositoryFile, $repositoryContent)) { + throw new IOException("Impossible to write entity class file (" . $repositoryFile . ").", 1); + } + /* + * Force Zend OPcache to reset file + */ + if (function_exists('opcache_invalidate')) { + opcache_invalidate($file, true); + opcache_invalidate($repositoryFile, true); + } + if (function_exists('apcu_clear_cache')) { + apcu_clear_cache(); + } + + \clearstatcache(true, $file); + \clearstatcache(true, $repositoryFile); + + return true; + } + return false; + } + + public function getSourceClassPath(): string + { + $folder = $this->getGeneratedEntitiesFolder(); + return $folder . DIRECTORY_SEPARATOR . $this->nodeType->getSourceEntityClassName() . '.php'; + } + + public function getRepositoryClassPath(): string + { + $folder = $this->getGeneratedRepositoriesFolder(); + return $folder . DIRECTORY_SEPARATOR . $this->nodeType->getSourceEntityClassName() . 'Repository.php'; + } + + /** + * Clear doctrine metadata cache and + * regenerate entity class file. + * + * @return $this + */ + public function updateSchema(): NodeTypeHandler + { + $this->regenerateEntityClass(); + $this->exportNodeTypeJsonFile(); + + return $this; + } + + /** + * Delete and recreate entity class file. + */ + public function regenerateEntityClass(): NodeTypeHandler + { + $this->removeSourceEntityClass(); + $this->generateSourceEntityClass(); + if (null !== $this->nodeType) { + $this->apiResourceGenerator->generate($this->nodeType); + } + + return $this; + } + + /** + * Delete node-type class from database. + * + * @return $this + */ + public function deleteSchema(): NodeTypeHandler + { + if (null !== $this->nodeType) { + $this->apiResourceGenerator->remove($this->nodeType); + } + $this->removeSourceEntityClass(); + $this->removeNodeTypeJsonFile(); + + return $this; + } + + /** + * Delete node-type inherited nodes and its database schema + * before removing it from node-types table. + * + * @return $this + * @throws \Psr\Container\ContainerExceptionInterface + * @throws \Psr\Container\NotFoundExceptionInterface + */ + public function deleteWithAssociations(): NodeTypeHandler + { + /* + * Delete every nodes + */ + $nodes = $this->objectManager + ->getRepository(Node::class) + ->setDisplayingNotPublishedNodes(true) + ->findBy([ + 'nodeType' => $this->getNodeType(), + ]); + + /** @var Node $node */ + foreach ($nodes as $node) { + /** @var NodeHandler $nodeHandler */ + $nodeHandler = $this->handlerFactory->getHandler($node); + $nodeHandler->removeWithChildrenAndAssociations(); + } + + /* + * Remove node type + */ + $this->objectManager->remove($this->getNodeType()); + $this->objectManager->flush(); + + /* + * Remove class and database table + */ + $this->deleteSchema(); + + return $this; + } + + /** + * Reset current node-type fields positions. + * + * @param bool $setPositions + * @return float Return the next position after the **last** field + */ + public function cleanPositions(bool $setPositions = false): float + { + $criteria = Criteria::create(); + $criteria->orderBy(['position' => 'ASC']); + $fields = $this->nodeType->getFields()->matching($criteria); + $i = 1; + /** @var NodeTypeField $field */ + foreach ($fields as $field) { + $field->setPosition($i); + $i++; + } + + return $i; + } +} diff --git a/lib/RoadizCoreBundle/src/EntityHandler/NodesSourcesHandler.php b/lib/RoadizCoreBundle/src/EntityHandler/NodesSourcesHandler.php new file mode 100644 index 00000000..860fa692 --- /dev/null +++ b/lib/RoadizCoreBundle/src/EntityHandler/NodesSourcesHandler.php @@ -0,0 +1,540 @@ +|null + */ + protected ?array $parentsNodeSources = null; + protected Settings $settingsBag; + + /** + * Create a new node-source handler with node-source to handle. + * + * @param ObjectManager $objectManager + * @param Settings $settingsBag + */ + public function __construct(ObjectManager $objectManager, Settings $settingsBag) + { + parent::__construct($objectManager); + + $this->settingsBag = $settingsBag; + } + + /** + * @return EntityRepository + */ + protected function getRepository(): EntityRepository + { + return $this->objectManager->getRepository(NodesSources::class); + } + + /** + * @return NodesSources + */ + public function getNodeSource(): NodesSources + { + if (null === $this->nodeSource) { + throw new \BadMethodCallException('NodesSources is null'); + } + return $this->nodeSource; + } + + /** + * @param NodesSources $nodeSource + * @return NodesSourcesHandler + */ + public function setNodeSource(NodesSources $nodeSource) + { + $this->nodeSource = $nodeSource; + return $this; + } + + /** + * Remove every node-source documents associations for a given field. + * + * @param NodeTypeField $field + * @param bool $flush + * @return $this + */ + public function cleanDocumentsFromField(NodeTypeField $field, bool $flush = true) + { + $this->nodeSource->clearDocumentsByFields($field); + + if (true === $flush) { + $this->objectManager->flush(); + } + + return $this; + } + + /** + * Add a document to current node-source for a given node-type field. + * + * @param Document $document + * @param NodeTypeField $field + * @param bool $flush + * @param null|float $position + * @return $this + */ + public function addDocumentForField( + Document $document, + NodeTypeField $field, + bool $flush = true, + ?float $position = null + ) { + $nsDoc = new NodesSourcesDocuments($this->nodeSource, $document, $field); + + if (!$this->nodeSource->hasNodesSourcesDocuments($nsDoc)) { + if (null === $position) { + $latestPosition = $this->objectManager + ->getRepository(NodesSourcesDocuments::class) + ->getLatestPosition($this->nodeSource, $field); + + $nsDoc->setPosition($latestPosition + 1); + } else { + $nsDoc->setPosition($position); + } + $this->nodeSource->addDocumentsByFields($nsDoc); + $this->objectManager->persist($nsDoc); + if (true === $flush) { + $this->objectManager->flush(); + } + } + + return $this; + } + + /** + * Get documents linked to current node-source for a given field name. + * + * @param string $fieldName Name of the node-type field + * @return array + */ + public function getDocumentsFromFieldName(string $fieldName): array + { + $field = $this->nodeSource->getNode()->getNodeType()->getFieldByName($fieldName); + if (null !== $field) { + return $this->objectManager + ->getRepository(Document::class) + ->findByNodeSourceAndField( + $this->nodeSource, + $field + ); + } + return []; + } + + /** + * Get a string describing uniquely the current nodeSource. + * + * Can be the urlAlias or the nodeName + * @deprecated Use directly NodesSources::getIdentifier + * @return string + */ + public function getIdentifier(): string + { + $urlAlias = $this->nodeSource->getUrlAliases()->first(); + if (is_object($urlAlias)) { + return $urlAlias->getAlias(); + } + + return $this->nodeSource->getNode()->getNodeName(); + } + + /** + * Get parent node-source to get the current translation. + * + * @deprecated Use directly NodesSources::getParent + * @return NodesSources|null + */ + public function getParent(): ?NodesSources + { + return $this->nodeSource->getParent(); + } + + /** + * Get every nodeSources parents from direct parent to farest ancestor. + * + * @param array|null $criteria + * @return array + * @throws \Doctrine\ORM\NonUniqueResultException + */ + public function getParents( + array $criteria = null + ): array { + if (null === $this->parentsNodeSources) { + $this->parentsNodeSources = []; + + if (null === $criteria) { + $criteria = []; + } + + $parent = $this->nodeSource; + + while (null !== $parent) { + $criteria = array_merge( + $criteria, + [ + 'node' => $parent->getNode()->getParent(), + 'translation' => $this->nodeSource->getTranslation(), + ] + ); + $currentParent = $this->getRepository()->findOneBy( + $criteria, + [] + ); + + if (null !== $currentParent) { + $this->parentsNodeSources[] = $currentParent; + } + + $parent = $currentParent; + } + } + + return $this->parentsNodeSources; + } + + /** + * Get children nodes sources to lock with current translation. + * + * @param array|null $criteria Additional criteria + * @param array|null $order Non default ordering + * + * @return array + */ + public function getChildren( + array $criteria = null, + array $order = null + ): array { + $defaultCrit = [ + 'node.parent' => $this->nodeSource->getNode(), + 'translation' => $this->nodeSource->getTranslation(), + ]; + + if (null !== $order) { + $defaultOrder = $order; + } else { + $defaultOrder = [ + 'node.position' => 'ASC', + ]; + } + + if (null !== $criteria) { + $defaultCrit = array_merge($defaultCrit, $criteria); + } + + return $this->getRepository()->findBy( + $defaultCrit, + $defaultOrder + ); + } + + /** + * Get first node-source among current node-source children. + * + * @param array|null $criteria + * @param array|null $order + * + * @return NodesSources|null + */ + public function getFirstChild( + array $criteria = null, + array $order = null + ): ?NodesSources { + $defaultCrit = [ + 'node.parent' => $this->nodeSource->getNode(), + 'translation' => $this->nodeSource->getTranslation() + ]; + + if (null !== $order) { + $defaultOrder = $order; + } else { + $defaultOrder = [ + 'node.position' => 'ASC', + ]; + } + + if (null !== $criteria) { + $defaultCrit = array_merge($defaultCrit, $criteria); + } + + return $this->getRepository()->findOneBy( + $defaultCrit, + $defaultOrder + ); + } + /** + * Get last node-source among current node-source children. + * + * @param array|null $criteria + * @param array|null $order + * + * @return NodesSources|null + */ + public function getLastChild( + array $criteria = null, + array $order = null + ): ?NodesSources { + $defaultCrit = [ + 'node.parent' => $this->nodeSource->getNode(), + 'translation' => $this->nodeSource->getTranslation(), + ]; + + if (null !== $order) { + $defaultOrder = $order; + } else { + $defaultOrder = [ + 'node.position' => 'DESC', + ]; + } + + if (null !== $criteria) { + $defaultCrit = array_merge($defaultCrit, $criteria); + } + + return $this->getRepository()->findOneBy( + $defaultCrit, + $defaultOrder + ); + } + + /** + * Get first node-source in the same parent as current node-source. + * + * @param array|null $criteria + * @param array|null $order + * + * @return NodesSources|null + */ + public function getFirstSibling( + array $criteria = null, + array $order = null + ): ?NodesSources { + if (null !== $this->nodeSource->getParent()) { + $parentHandler = new NodesSourcesHandler($this->objectManager, $this->settingsBag); + $parentHandler->setNodeSource($this->nodeSource->getParent()); + return $parentHandler->getFirstChild($criteria, $order); + } else { + $criteria['node.parent'] = null; + return $this->getFirstChild($criteria, $order); + } + } + + /** + * Get last node-source in the same parent as current node-source. + * + * @param array|null $criteria + * @param array|null $order + * + * @return NodesSources|null + */ + public function getLastSibling( + array $criteria = null, + array $order = null + ): ?NodesSources { + if (null !== $this->nodeSource->getParent()) { + $parentHandler = new NodesSourcesHandler($this->objectManager, $this->settingsBag); + $parentHandler->setNodeSource($this->nodeSource->getParent()); + return $parentHandler->getLastChild($criteria, $order); + } else { + $criteria['node.parent'] = null; + return $this->getLastChild($criteria, $order); + } + } + + /** + * Get previous node-source from hierarchy. + * + * @param array|null $criteria + * @param array|null $order + * + * @return NodesSources|null + */ + public function getPrevious( + array $criteria = null, + array $order = null + ): ?NodesSources { + if ($this->nodeSource->getNode()->getPosition() <= 1) { + return null; + } + + $defaultCriteria = [ + /* + * Use < operator to get first next nodeSource + * even if it’s not the next position index + */ + 'node.position' => [ + '<', + $this->nodeSource + ->getNode() + ->getPosition(), + ], + 'node.parent' => $this->nodeSource->getNode()->getParent(), + 'translation' => $this->nodeSource->getTranslation(), + ]; + if (null !== $criteria) { + $defaultCriteria = array_merge($defaultCriteria, $criteria); + } + + if (null === $order) { + $order = []; + } + + $order['node.position'] = 'DESC'; + + return $this->getRepository()->findOneBy( + $defaultCriteria, + $order + ); + } + + /** + * Get next node-source from hierarchy. + * + * @param array|null $criteria + * @param array|null $order + * + * @return NodesSources|null + */ + public function getNext( + array $criteria = null, + array $order = null + ): ?NodesSources { + $defaultCrit = [ + /* + * Use > operator to get first next nodeSource + * even if it’s not the next position index + */ + 'node.position' => [ + '>', + $this->nodeSource + ->getNode() + ->getPosition(), + ], + 'node.parent' => $this->nodeSource->getNode()->getParent(), + 'translation' => $this->nodeSource->getTranslation(), + ]; + if (null !== $criteria) { + $defaultCrit = array_merge($defaultCrit, $criteria); + } + + if (null === $order) { + $order = []; + } + + $order['node.position'] = 'ASC'; + + return $this->getRepository()->findOneBy( + $defaultCrit, + $order + ); + } + + /** + * Get node tags with current source translation. + * + * @return array + */ + public function getTags() + { + /** + * @phpstan-ignore-next-line + */ + return $this->objectManager->getRepository(Tag::class)->findBy([ + "nodes" => $this->nodeSource->getNode(), + "translation" => $this->nodeSource->getTranslation(), + ], [ + 'position' => 'ASC', + ]); + } + + /** + * Get current node-source SEO data. + * + * This method returns a 3-fields array with: + * + * * title + * * description + * * keywords + * + * @return array + */ + public function getSEO(): array + { + return [ + 'title' => ($this->nodeSource->getMetaTitle() != "") ? + $this->nodeSource->getMetaTitle() : + $this->nodeSource->getTitle() . ' – ' . $this->settingsBag->get('site_name'), + 'description' => ($this->nodeSource->getMetaDescription() != "") ? + $this->nodeSource->getMetaDescription() : + $this->nodeSource->getTitle() . ', ' . $this->settingsBag->get('seo_description'), + 'keywords' => $this->nodeSource->getMetaKeywords(), + ]; + } + + /** + * Get nodes linked to current node for a given fieldname. + * + * @param string $fieldName Name of the node-type field + * + * @return array Collection of nodes + */ + public function getNodesFromFieldName(string $fieldName) + { + $field = $this->nodeSource->getNode()->getNodeType()->getFieldByName($fieldName); + if (null !== $field) { + return $this->objectManager + ->getRepository(Node::class) + ->findByNodeAndFieldAndTranslation( + $this->nodeSource->getNode(), + $field, + $this->nodeSource->getTranslation() + ); + } + return []; + } + + /** + * Get nodes which own a reference to current node for a given fieldname. + * + * @param string $fieldName Name of the node-type field + * + * @return array Collection of nodes + */ + public function getReverseNodesFromFieldName(string $fieldName) + { + $field = $this->nodeSource->getNode()->getNodeType()->getFieldByName($fieldName); + if (null !== $field) { + return $this->objectManager + ->getRepository(Node::class) + ->findByReverseNodeAndFieldAndTranslation( + $this->nodeSource->getNode(), + $field, + $this->nodeSource->getTranslation() + ); + } + return []; + } +} diff --git a/lib/RoadizCoreBundle/src/EntityHandler/TagHandler.php b/lib/RoadizCoreBundle/src/EntityHandler/TagHandler.php new file mode 100644 index 00000000..b99e28c2 --- /dev/null +++ b/lib/RoadizCoreBundle/src/EntityHandler/TagHandler.php @@ -0,0 +1,280 @@ +tag) { + throw new \BadMethodCallException('Tag is null'); + } + return $this->tag; + } + + /** + * @param Tag $tag + * @return $this + */ + public function setTag(Tag $tag) + { + $this->tag = $tag; + return $this; + } + + /** + * Remove only current tag children. + * + * @return $this + */ + private function removeChildren() + { + /** @var Tag $tag */ + foreach ($this->tag->getChildren() as $tag) { + $handler = new TagHandler($this->objectManager); + $handler->setTag($tag); + $handler->removeWithChildrenAndAssociations(); + } + + return $this; + } + /** + * Remove only current tag associations. + * + * @return $this + */ + public function removeAssociations() + { + foreach ($this->tag->getTranslatedTags() as $tt) { + $this->objectManager->remove($tt); + } + + return $this; + } + /** + * Remove current tag with its children recursively and + * its associations. + * + * @return $this + */ + public function removeWithChildrenAndAssociations() + { + $this->removeChildren(); + $this->removeAssociations(); + + $this->objectManager->remove($this->tag); + + /* + * Final flush + */ + $this->objectManager->flush(); + + return $this; + } + + /** + * @return array Array of Translation + * @deprecated Do not query DB here + */ + public function getAvailableTranslations() + { + $query = $this->objectManager + ->createQuery(' + SELECT tr + FROM RZ\Roadiz\CoreBundle\Entity\Translation tr + INNER JOIN tr.tagTranslations tt + INNER JOIN tt.tag t + WHERE t.id = :tag_id') + ->setParameter('tag_id', $this->tag->getId()); + + try { + return $query->getResult(); + } catch (NoResultException $e) { + return []; + } + } + /** + * @return array Array of Translation id + * @deprecated Do not query DB here + */ + public function getAvailableTranslationsId() + { + $query = $this->objectManager + ->createQuery(' + SELECT tr.id FROM RZ\Roadiz\CoreBundle\Entity\Tag t + INNER JOIN t.translatedTags tt + INNER JOIN tt.translation tr + WHERE t.id = :tag_id') + ->setParameter('tag_id', $this->tag->getId()); + + try { + $simpleArray = []; + $complexArray = $query->getScalarResult(); + foreach ($complexArray as $subArray) { + $simpleArray[] = $subArray['id']; + } + + return $simpleArray; + } catch (NoResultException $e) { + return []; + } + } + + /** + * @return array Array of Translation + * @deprecated Do not query DB here + */ + public function getUnavailableTranslations() + { + $query = $this->objectManager + ->createQuery(' + SELECT tr FROM RZ\Roadiz\CoreBundle\Entity\Translation tr + WHERE tr.id NOT IN (:translations_id)') + ->setParameter('translations_id', $this->getAvailableTranslationsId()); + + try { + return $query->getResult(); + } catch (NoResultException $e) { + return []; + } + } + + /** + * @return array Array of Translation id + * @deprecated Do not query DB here + */ + public function getUnavailableTranslationsId() + { + /** @var Query $query */ + $query = $this->objectManager + ->createQuery(' + SELECT t.id FROM RZ\Roadiz\CoreBundle\Entity\Translation t + WHERE t.id NOT IN (:translations_id)') + ->setParameter('translations_id', $this->getAvailableTranslationsId()); + + try { + $simpleArray = []; + $complexArray = $query->getScalarResult(); + foreach ($complexArray as $subArray) { + $simpleArray[] = $subArray['id']; + } + + return $simpleArray; + } catch (NoResultException $e) { + return []; + } + } + + /** + * Return every tag’s parents. + * @deprecated Use directly Tag::getParents + * @return array + */ + public function getParents() + { + return $this->tag->getParents(); + } + + /** + * Get tag full path using tag names. + * + * @deprecated Use directly Tag::getFullPath + * @return string + */ + public function getFullPath(): string + { + return $this->tag->getFullPath(); + } + + /** + * Clean position for current tag siblings. + * + * @param bool $setPositions + * @return float Return the next position after the **last** tag + */ + public function cleanPositions(bool $setPositions = true): float + { + if ($this->tag->getParent() !== null) { + $tagHandler = new TagHandler($this->objectManager); + /** @var Tag|null $parent */ + $parent = $this->tag->getParent(); + $tagHandler->setTag($parent); + return $tagHandler->cleanChildrenPositions($setPositions); + } else { + return $this->cleanRootTagsPositions($setPositions); + } + } + + /** + * Reset current tag children positions. + * + * Warning, this method does not flush. + * + * @param bool $setPositions + * @return float Return the next position after the **last** tag + */ + public function cleanChildrenPositions(bool $setPositions = true): float + { + /* + * Force collection to sort on position + */ + $sort = Criteria::create(); + $sort->orderBy([ + 'position' => Criteria::ASC + ]); + + $children = $this->tag->getChildren()->matching($sort); + $i = 1; + /** @var Tag $child */ + foreach ($children as $child) { + if ($setPositions) { + $child->setPosition($i); + } + $i++; + } + + return $i; + } + + /** + * Reset every root tags positions. + * + * Warning, this method does not flush. + * + * @param bool $setPositions + * @return float Return the next position after the **last** tag + */ + public function cleanRootTagsPositions(bool $setPositions = true): float + { + $tags = $this->objectManager + ->getRepository(Tag::class) + ->findBy(['parent' => null], ['position' => 'ASC']); + + $i = 1; + /** @var Tag $child */ + foreach ($tags as $child) { + if ($setPositions) { + $child->setPosition($i); + } + $i++; + } + + return $i; + } +} diff --git a/lib/RoadizCoreBundle/src/EntityHandler/TranslationHandler.php b/lib/RoadizCoreBundle/src/EntityHandler/TranslationHandler.php new file mode 100644 index 00000000..7a9d9673 --- /dev/null +++ b/lib/RoadizCoreBundle/src/EntityHandler/TranslationHandler.php @@ -0,0 +1,74 @@ +translation) { + throw new \BadMethodCallException('Translation is null'); + } + return $this->translation; + } + + /** + * @param TranslationInterface $translation + * + * @return $this + */ + public function setTranslation(TranslationInterface $translation) + { + $this->translation = $translation; + return $this; + } + + /** + * Set current translation as default one. + * + * @return $this + */ + public function makeDefault() + { + $defaults = $this->objectManager + ->getRepository(Translation::class) + ->findBy(['defaultTranslation' => true]); + + /** @var TranslationInterface $default */ + foreach ($defaults as $default) { + $default->setDefaultTranslation(false); + } + $this->objectManager->flush(); + $this->translation->setDefaultTranslation(true); + $this->objectManager->flush(); + + if ($this->objectManager instanceof EntityManagerInterface) { + $cache = $this->objectManager->getConfiguration()->getResultCacheImpl(); + if ($cache instanceof FlushableCache) { + $cache->flushAll(); + } + if ($cache instanceof ResettableInterface) { + $cache->reset(); + } + } + + return $this; + } +} diff --git a/lib/RoadizCoreBundle/src/Event/Cache/CachePurgeRequestEvent.php b/lib/RoadizCoreBundle/src/Event/Cache/CachePurgeRequestEvent.php new file mode 100644 index 00000000..53645ce2 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Event/Cache/CachePurgeRequestEvent.php @@ -0,0 +1,11 @@ +documentTranslation = $documentTranslation; + $this->associations = $associations; + $this->solariumDocument = $solariumDocument; + $this->subResource = $subResource; + } + + /** + * @return DocumentTranslation + */ + public function getDocumentTranslation(): DocumentTranslation + { + return $this->documentTranslation; + } + + /** + * @return array + */ + public function getAssociations(): array + { + return $this->associations; + } + + /** + * @return AbstractSolarium + */ + public function getSolariumDocument(): AbstractSolarium + { + return $this->solariumDocument; + } + + /** + * @return bool + */ + public function isSubResource(): bool + { + return $this->subResource; + } + + /** + * @param array $associations + * @return DocumentTranslationIndexingEvent + */ + public function setAssociations(array $associations): DocumentTranslationIndexingEvent + { + $this->associations = $associations; + return $this; + } + + /** + * @param AbstractSolarium $solariumDocument + * @return DocumentTranslationIndexingEvent + */ + public function setSolariumDocument(AbstractSolarium $solariumDocument): DocumentTranslationIndexingEvent + { + $this->solariumDocument = $solariumDocument; + return $this; + } +} diff --git a/lib/RoadizCoreBundle/src/Event/Document/DocumentTranslationUpdatedEvent.php b/lib/RoadizCoreBundle/src/Event/Document/DocumentTranslationUpdatedEvent.php new file mode 100644 index 00000000..8abf8b0e --- /dev/null +++ b/lib/RoadizCoreBundle/src/Event/Document/DocumentTranslationUpdatedEvent.php @@ -0,0 +1,28 @@ +documentTranslation = $documentTranslation; + } + + /** + * @return DocumentTranslation|null + */ + public function getDocumentTranslation(): ?DocumentTranslation + { + return $this->documentTranslation; + } +} diff --git a/lib/RoadizCoreBundle/src/Event/FilterCacheEvent.php b/lib/RoadizCoreBundle/src/Event/FilterCacheEvent.php new file mode 100644 index 00000000..934e18b5 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Event/FilterCacheEvent.php @@ -0,0 +1,68 @@ +messageCollection = new ArrayCollection(); + $this->errorCollection = new ArrayCollection(); + } + + /** + * @param string $message + * @param string|null $classname + * @param string|null $description + */ + public function addMessage(string $message, ?string $classname = null, ?string $description = null): void + { + $this->messageCollection->add([ + "clearer" => $classname, + "description" => $description, + "message" => $message, + ]); + } + + /** + * @param string $message + * @param string|null $classname + * @param string|null $description + */ + public function addError(string $message, ?string $classname = null, ?string $description = null): void + { + $this->errorCollection->add([ + "clearer" => $classname, + "description" => $description, + "message" => $message, + ]); + } + + /** + * @return array + */ + public function getErrors(): array + { + return $this->errorCollection->toArray(); + } + + /** + * @return array + */ + public function getMessages(): array + { + return $this->messageCollection->toArray(); + } +} diff --git a/lib/RoadizCoreBundle/src/Event/FilterFolderEvent.php b/lib/RoadizCoreBundle/src/Event/FilterFolderEvent.php new file mode 100644 index 00000000..eb426b11 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Event/FilterFolderEvent.php @@ -0,0 +1,26 @@ +folder = $folder; + } + + public function getFolder(): Folder + { + return $this->folder; + } +} diff --git a/lib/RoadizCoreBundle/src/Event/FilterNodeEvent.php b/lib/RoadizCoreBundle/src/Event/FilterNodeEvent.php new file mode 100644 index 00000000..c04c2639 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Event/FilterNodeEvent.php @@ -0,0 +1,26 @@ +node = $node; + } + + public function getNode(): Node + { + return $this->node; + } +} diff --git a/lib/RoadizCoreBundle/src/Event/FilterNodePathEvent.php b/lib/RoadizCoreBundle/src/Event/FilterNodePathEvent.php new file mode 100644 index 00000000..ed4cc2f9 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Event/FilterNodePathEvent.php @@ -0,0 +1,44 @@ +paths = $paths; + $this->updatedAt = $updatedAt; + } + + /** + * @return array + */ + public function getPaths(): array + { + return $this->paths; + } + + /** + * @return \DateTime|null + */ + public function getUpdatedAt(): ?\DateTime + { + return $this->updatedAt; + } +} diff --git a/lib/RoadizCoreBundle/src/Event/FilterNodesSourcesEvent.php b/lib/RoadizCoreBundle/src/Event/FilterNodesSourcesEvent.php new file mode 100644 index 00000000..be277645 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Event/FilterNodesSourcesEvent.php @@ -0,0 +1,26 @@ +nodeSource = $nodeSource; + } + + public function getNodeSource(): NodesSources + { + return $this->nodeSource; + } +} diff --git a/lib/RoadizCoreBundle/src/Event/FilterSettingEvent.php b/lib/RoadizCoreBundle/src/Event/FilterSettingEvent.php new file mode 100644 index 00000000..edbd0d1c --- /dev/null +++ b/lib/RoadizCoreBundle/src/Event/FilterSettingEvent.php @@ -0,0 +1,23 @@ +setting = $setting; + } + + public function getSetting(): Setting + { + return $this->setting; + } +} diff --git a/lib/RoadizCoreBundle/src/Event/FilterTagEvent.php b/lib/RoadizCoreBundle/src/Event/FilterTagEvent.php new file mode 100644 index 00000000..9bb701d0 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Event/FilterTagEvent.php @@ -0,0 +1,29 @@ +tag = $tag; + } + + public function getTag(): Tag + { + return $this->tag; + } +} diff --git a/lib/RoadizCoreBundle/src/Event/FilterTranslationEvent.php b/lib/RoadizCoreBundle/src/Event/FilterTranslationEvent.php new file mode 100644 index 00000000..2e66b738 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Event/FilterTranslationEvent.php @@ -0,0 +1,26 @@ +translation = $translation; + } + + public function getTranslation(): Translation + { + return $this->translation; + } +} diff --git a/lib/RoadizCoreBundle/src/Event/FilterUrlAliasEvent.php b/lib/RoadizCoreBundle/src/Event/FilterUrlAliasEvent.php new file mode 100644 index 00000000..922c8c70 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Event/FilterUrlAliasEvent.php @@ -0,0 +1,26 @@ +urlAlias = $urlAlias; + } + + public function getUrlAlias(): UrlAlias + { + return $this->urlAlias; + } +} diff --git a/lib/RoadizCoreBundle/src/Event/FilterUserEvent.php b/lib/RoadizCoreBundle/src/Event/FilterUserEvent.php new file mode 100644 index 00000000..449e75a6 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Event/FilterUserEvent.php @@ -0,0 +1,26 @@ +user = $user; + } + + public function getUser(): User + { + return $this->user; + } +} diff --git a/lib/RoadizCoreBundle/src/Event/Folder/FolderCreatedEvent.php b/lib/RoadizCoreBundle/src/Event/Folder/FolderCreatedEvent.php new file mode 100644 index 00000000..9ed1cce8 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Event/Folder/FolderCreatedEvent.php @@ -0,0 +1,11 @@ +nodeSource = $nodeSource; + $this->associations = $associations; + $this->solariumDocument = $solariumDocument; + $this->subResource = $subResource; + } + + public function getNodeSource(): NodesSources + { + return $this->nodeSource; + } + + /** + * Get Solr document data to index. + * + * @return array + */ + public function getAssociations(): array + { + return $this->associations; + } + + /** + * Set Solr document data to index. + * + * @param array $associations + * @return NodesSourcesIndexingEvent + */ + public function setAssociations(array $associations): NodesSourcesIndexingEvent + { + $this->associations = $associations; + return $this; + } + + /** + * @return AbstractSolarium + */ + public function getSolariumDocument(): AbstractSolarium + { + return $this->solariumDocument; + } + + /** + * @param AbstractSolarium $solariumDocument + * + * @return NodesSourcesIndexingEvent + */ + public function setSolariumDocument(AbstractSolarium $solariumDocument): NodesSourcesIndexingEvent + { + $this->solariumDocument = $solariumDocument; + + return $this; + } + + /** + * @return bool + */ + public function isSubResource(): bool + { + return $this->subResource; + } +} diff --git a/lib/RoadizCoreBundle/src/Event/NodesSources/NodesSourcesPathGeneratingEvent.php b/lib/RoadizCoreBundle/src/Event/NodesSources/NodesSourcesPathGeneratingEvent.php new file mode 100644 index 00000000..b2cd984b --- /dev/null +++ b/lib/RoadizCoreBundle/src/Event/NodesSources/NodesSourcesPathGeneratingEvent.php @@ -0,0 +1,220 @@ +theme = $theme; + $this->nodeSource = $nodeSource; + $this->requestContext = $requestContext; + $this->forceLocale = $forceLocale; + $this->parameters = $parameters; + $this->forceLocaleWithUrlAlias = $forceLocaleWithUrlAlias; + } + + /** + * @return Theme|null + */ + public function getTheme(): ?Theme + { + return $this->theme; + } + + /** + * @return NodesSources|null + */ + public function getNodeSource(): ?NodesSources + { + return $this->nodeSource; + } + + /** + * @param NodesSources|null $nodeSource + * + * @return NodesSourcesPathGeneratingEvent + */ + public function setNodeSource(?NodesSources $nodeSource): NodesSourcesPathGeneratingEvent + { + $this->nodeSource = $nodeSource; + + return $this; + } + + /** + * @return RequestContext|null + */ + public function getRequestContext(): ?RequestContext + { + return $this->requestContext; + } + + /** + * @return bool + */ + public function isForceLocale(): bool + { + return $this->forceLocale; + } + + /** + * @return string|null + */ + public function getPath(): ?string + { + return $this->path; + } + + /** + * @param string|null $path + * + * @return NodesSourcesPathGeneratingEvent + */ + public function setPath(?string $path): NodesSourcesPathGeneratingEvent + { + $this->path = $path; + + return $this; + } + + /** + * @return array|null + */ + public function getParameters(): ?array + { + return $this->parameters; + } + + /** + * @param array|null $parameters + * + * @return NodesSourcesPathGeneratingEvent + */ + public function setParameters(?array $parameters): NodesSourcesPathGeneratingEvent + { + $this->parameters = $parameters; + + return $this; + } + + /** + * @return bool + */ + public function isComplete(): bool + { + return $this->isComplete; + } + + /** + * @param bool $isComplete + * + * @return NodesSourcesPathGeneratingEvent + */ + public function setComplete(bool $isComplete): NodesSourcesPathGeneratingEvent + { + $this->isComplete = $isComplete; + + return $this; + } + + /** + * @return bool + */ + public function containsScheme(): bool + { + return $this->containsScheme; + } + + /** + * @param bool $containsScheme + * + * @return NodesSourcesPathGeneratingEvent + */ + public function setContainsScheme(bool $containsScheme): NodesSourcesPathGeneratingEvent + { + $this->containsScheme = $containsScheme; + + return $this; + } + + /** + * @return bool + */ + public function isForceLocaleWithUrlAlias(): bool + { + return $this->forceLocaleWithUrlAlias; + } + + /** + * @param bool $forceLocaleWithUrlAlias + * + * @return NodesSourcesPathGeneratingEvent + */ + public function setForceLocaleWithUrlAlias(bool $forceLocaleWithUrlAlias): NodesSourcesPathGeneratingEvent + { + $this->forceLocaleWithUrlAlias = $forceLocaleWithUrlAlias; + + return $this; + } +} diff --git a/lib/RoadizCoreBundle/src/Event/NodesSources/NodesSourcesPreUpdatedEvent.php b/lib/RoadizCoreBundle/src/Event/NodesSources/NodesSourcesPreUpdatedEvent.php new file mode 100644 index 00000000..2c7dcfc6 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Event/NodesSources/NodesSourcesPreUpdatedEvent.php @@ -0,0 +1,11 @@ +realmNode = $realmNode; + } + + /** + * @return RealmNode + */ + public function getRealmNode(): RealmNode + { + return $this->realmNode; + } +} diff --git a/lib/RoadizCoreBundle/src/Event/Realm/NodeJoinedRealmEvent.php b/lib/RoadizCoreBundle/src/Event/Realm/NodeJoinedRealmEvent.php new file mode 100644 index 00000000..69c48df2 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Event/Realm/NodeJoinedRealmEvent.php @@ -0,0 +1,9 @@ +role = $role; + } + + /** + * @return Role|null + */ + public function getRole(): ?Role + { + return $this->role; + } + + /** + * @param Role|null $role + * @return RoleEvent + */ + public function setRole(?Role $role): RoleEvent + { + $this->role = $role; + return $this; + } +} diff --git a/lib/RoadizCoreBundle/src/Event/Setting/SettingCreatedEvent.php b/lib/RoadizCoreBundle/src/Event/Setting/SettingCreatedEvent.php new file mode 100644 index 00000000..b0944e36 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Event/Setting/SettingCreatedEvent.php @@ -0,0 +1,11 @@ +group = $group; + } + + /** + * @return Group + */ + public function getGroup(): Group + { + return $this->group; + } +} diff --git a/lib/RoadizCoreBundle/src/Event/User/UserLeavedGroupEvent.php b/lib/RoadizCoreBundle/src/Event/User/UserLeavedGroupEvent.php new file mode 100644 index 00000000..aa250160 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Event/User/UserLeavedGroupEvent.php @@ -0,0 +1,28 @@ +group = $group; + } + + /** + * @return Group + */ + public function getGroup(): Group + { + return $this->group; + } +} diff --git a/lib/RoadizCoreBundle/src/Event/User/UserPasswordChangedEvent.php b/lib/RoadizCoreBundle/src/Event/User/UserPasswordChangedEvent.php new file mode 100644 index 00000000..86199e60 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Event/User/UserPasswordChangedEvent.php @@ -0,0 +1,11 @@ +assetsClearer = $assetsClearer; + $this->logger = $logger; + } + + /** + * @inheritDoc + */ + public static function getSubscribedEvents(): array + { + return [ + CachePurgeAssetsRequestEvent::class => ['onPurgeAssetsRequest', 0], + '\RZ\Roadiz\Core\Events\Cache\CachePurgeAssetsRequestEvent' => ['onPurgeAssetsRequest', 0], + ]; + } + + public function onPurgeAssetsRequest(CachePurgeAssetsRequestEvent $event): void + { + try { + $this->assetsClearer->clear(); + $this->logger->info($this->assetsClearer->getOutput()); + } catch (\Exception $e) { + $this->logger->error($e->getMessage()); + } + } +} diff --git a/lib/RoadizCoreBundle/src/EventSubscriber/AttributeValueIndexingSubscriber.php b/lib/RoadizCoreBundle/src/EventSubscriber/AttributeValueIndexingSubscriber.php new file mode 100644 index 00000000..bd4a3e6b --- /dev/null +++ b/lib/RoadizCoreBundle/src/EventSubscriber/AttributeValueIndexingSubscriber.php @@ -0,0 +1,90 @@ + 'onNodeSourceIndexing', + ]; + } + + public function onNodeSourceIndexing(NodesSourcesIndexingEvent $event): void + { + if ($event->getNodeSource()->getNode()->getAttributeValues()->count() === 0) { + return; + } + + $associations = $event->getAssociations(); + $attributeValues = $event->getNodeSource() + ->getNode() + ->getAttributesValuesForTranslation($event->getNodeSource()->getTranslation()); + + /** @var AttributeValueInterface $attributeValue */ + foreach ($attributeValues as $attributeValue) { + if ($attributeValue->getAttribute()->isSearchable()) { + $data = $attributeValue->getAttributeValueTranslation( + $event->getNodeSource()->getTranslation() + )->getValue(); + if (null === $data) { + $data = $attributeValue->getAttributeValueTranslations()->first()->getValue(); + } + if (null !== $data) { + switch ($attributeValue->getType()) { + case AttributeInterface::DATETIME_T: + case AttributeInterface::DATE_T: + if ($data instanceof \DateTime) { + $fieldName = $attributeValue->getAttribute()->getCode() . '_dt'; + $associations[$fieldName] = $data->format('Y-m-d\TH:i:s'); + } + break; + case AttributeInterface::STRING_T: + $fieldName = $attributeValue->getAttribute()->getCode(); + /* + * Use locale to create field name + * with right language + */ + if ( + in_array( + $event->getNodeSource()->getTranslation()->getLocale(), + AbstractSolarium::$availableLocalizedTextFields + ) + ) { + $lang = $event->getNodeSource()->getTranslation()->getLocale(); + $fieldName .= '_txt_' . $lang; + } else { + $lang = null; + $fieldName .= '_t'; + } + /* + * Strip Markdown syntax + */ + $content = $event->getSolariumDocument()->cleanTextContent($data); + $associations[$fieldName] = $content; + $associations['collection_txt'][] = $content; + if (null !== $lang) { + // Compile all text content into a single localized text field. + $associations['collection_txt_' . $lang] = implode(PHP_EOL, $associations['collection_txt']); + } + break; + } + } + } + } + + $event->setAssociations($associations); + } +} diff --git a/lib/RoadizCoreBundle/src/EventSubscriber/AutomaticWebhookSubscriber.php b/lib/RoadizCoreBundle/src/EventSubscriber/AutomaticWebhookSubscriber.php new file mode 100644 index 00000000..1e6ba43f --- /dev/null +++ b/lib/RoadizCoreBundle/src/EventSubscriber/AutomaticWebhookSubscriber.php @@ -0,0 +1,149 @@ +webhookDispatcher = $webhookDispatcher; + $this->handlerFactory = $handlerFactory; + $this->managerRegistry = $managerRegistry; + } + + public static function getSubscribedEvents(): array + { + return [ + 'workflow.node.completed' => ['onAutomaticWebhook'], + NodeVisibilityChangedEvent::class => 'onAutomaticWebhook', + '\RZ\Roadiz\Core\Events\Node\NodeVisibilityChangedEvent' => 'onAutomaticWebhook', + NodesSourcesPreUpdatedEvent::class => 'onAutomaticWebhook', + '\RZ\Roadiz\Core\Events\NodesSources\NodesSourcesPreUpdatedEvent' => 'onAutomaticWebhook', + NodesSourcesDeletedEvent::class => 'onAutomaticWebhook', + '\RZ\Roadiz\Core\Events\NodesSources\NodesSourcesDeletedEvent' => 'onAutomaticWebhook', + NodeUpdatedEvent::class => 'onAutomaticWebhook', + '\RZ\Roadiz\Core\Events\Node\NodeUpdatedEvent' => 'onAutomaticWebhook', + NodeDeletedEvent::class => 'onAutomaticWebhook', + '\RZ\Roadiz\Core\Events\Node\NodeDeletedEvent' => 'onAutomaticWebhook', + NodeTaggedEvent::class => 'onAutomaticWebhook', + '\RZ\Roadiz\Core\Events\Node\NodeTaggedEvent' => 'onAutomaticWebhook', + TagUpdatedEvent::class => 'onAutomaticWebhook', + '\RZ\Roadiz\Core\Events\Tag\TagUpdatedEvent' => 'onAutomaticWebhook', + DocumentTranslationUpdatedEvent::class => 'onAutomaticWebhook', + '\RZ\Roadiz\Core\Events\DocumentTranslationUpdatedEvent' => 'onAutomaticWebhook', + DocumentUpdatedEvent::class => 'onAutomaticWebhook', + ]; + } + + /** + * @param mixed $event + * @return bool + */ + protected function isEventRelatedToNode(mixed $event): bool + { + return $event instanceof Event || + $event instanceof NodeVisibilityChangedEvent || + $event instanceof NodesSourcesPreUpdatedEvent || + $event instanceof NodesSourcesDeletedEvent || + $event instanceof NodeUpdatedEvent || + $event instanceof NodeDeletedEvent || + $event instanceof NodeTaggedEvent; + } + + /** + * @param Event|NodeVisibilityChangedEvent|NodesSourcesPreUpdatedEvent|NodesSourcesDeletedEvent|NodeDeletedEvent|NodeTaggedEvent|TagUpdatedEvent|DocumentTranslationUpdatedEvent|DocumentUpdatedEvent $event + */ + public function onAutomaticWebhook(mixed $event): void + { + /** @var Webhook[] $webhooks */ + $webhooks = $this->managerRegistry->getRepository(Webhook::class)->findBy([ + 'automatic' => true + ]); + foreach ($webhooks as $webhook) { + if (!$this->isEventRelatedToNode($event) || $this->isEventSubjectInRootNode($event, $webhook->getRootNode())) { + /* + * Always Triggers automatic webhook if there is no registered root node, or + * event is not related to a node. + */ + try { + $this->webhookDispatcher->dispatch($webhook); + } catch (TooManyWebhookTriggeredException $e) { + // do nothing + } + } + } + } + + private function isEventSubjectInRootNode(mixed $event, ?Node $rootNode): bool + { + if (null === $rootNode) { + /* + * If root node does not exist, subject is always in root. + */ + return true; + } + /** @var Node|null $subject */ + $subject = null; + + switch (true) { + case $event instanceof Event: + $subject = $event->getSubject(); + if (!$subject instanceof Node) { + return false; + } + break; + case $event instanceof NodeUpdatedEvent: + case $event instanceof NodeDeletedEvent: + case $event instanceof NodeTaggedEvent: + case $event instanceof NodeVisibilityChangedEvent: + $subject = $event->getNode(); + break; + case $event instanceof NodesSourcesPreUpdatedEvent: + case $event instanceof NodesSourcesDeletedEvent: + $subject = $event->getNodeSource()->getNode(); + break; + default: + return false; + } + + $handler = $this->handlerFactory->getHandler($subject); + if ($handler instanceof NodeHandler) { + return $handler->isRelatedToNode($rootNode); + } + + return false; + } +} diff --git a/lib/RoadizCoreBundle/src/EventSubscriber/CloudflareCacheEventSubscriber.php b/lib/RoadizCoreBundle/src/EventSubscriber/CloudflareCacheEventSubscriber.php new file mode 100644 index 00000000..04b513bd --- /dev/null +++ b/lib/RoadizCoreBundle/src/EventSubscriber/CloudflareCacheEventSubscriber.php @@ -0,0 +1,217 @@ +logger = $logger; + $this->bus = $bus; + $this->reverseProxyCacheLocator = $reverseProxyCacheLocator; + $this->urlGenerator = $urlGenerator; + } + /** + * @inheritDoc + */ + public static function getSubscribedEvents(): array + { + return [ + CachePurgeRequestEvent::class => ['onBanRequest', 3], + '\RZ\Roadiz\Core\Events\Cache\CachePurgeRequestEvent' => ['onBanRequest', 3], + NodesSourcesUpdatedEvent::class => ['onPurgeRequest', 3], + '\RZ\Roadiz\Core\Events\NodesSources\NodesSourcesUpdatedEvent' => ['onPurgeRequest', 3], + ]; + } + + /** + * @return bool + */ + protected function supportConfig(): bool + { + return null !== $this->reverseProxyCacheLocator->getCloudflareProxyCache(); + } + + /** + * @param CachePurgeRequestEvent $event + * @throws \GuzzleHttp\Exception\GuzzleException + * @return void + */ + public function onBanRequest(CachePurgeRequestEvent $event): void + { + if (!$this->supportConfig()) { + return; + } + try { + $request = $this->createBanRequest(); + $this->sendRequest($request); + $event->addMessage( + 'Cloudflare cache cleared.', + self::class, + 'Cloudflare proxy cache' + ); + } catch (RequestException $e) { + if (null !== $e->getResponse()) { + $data = \json_decode($e->getResponse()->getBody()->getContents(), true); + $event->addError( + $data['errors'][0]['message'] ?? $e->getMessage(), + self::class, + 'Cloudflare proxy cache' + ); + } else { + $event->addError( + $e->getMessage(), + self::class, + 'Cloudflare proxy cache' + ); + } + } catch (ConnectException $e) { + $event->addError( + $e->getMessage(), + self::class, + 'Cloudflare proxy cache' + ); + } + } + + /** + * @param NodesSourcesUpdatedEvent $event + * + * @throws \GuzzleHttp\Exception\GuzzleException + */ + public function onPurgeRequest(NodesSourcesUpdatedEvent $event): void + { + if (!$this->supportConfig()) { + return; + } + + try { + $nodeSource = $event->getNodeSource(); + while (!$nodeSource->isReachable()) { + $nodeSource = $nodeSource->getParent(); + if (null === $nodeSource) { + return; + } + } + + $purgeRequest = $this->createPurgeRequest([$this->urlGenerator->generate( + RouteObjectInterface::OBJECT_BASED_ROUTE_NAME, + [ + RouteObjectInterface::ROUTE_OBJECT => $nodeSource, + ], + UrlGeneratorInterface::ABSOLUTE_URL + )]); + $this->sendRequest($purgeRequest); + } catch (ClientException $e) { + // do nothing + } + } + + private function getCloudflareCacheProxy(): CloudflareProxyCache + { + $proxy = $this->reverseProxyCacheLocator->getCloudflareProxyCache(); + if (null === $proxy) { + throw new \RuntimeException('Cloudflare cache proxy is not configured'); + } + return $proxy; + } + + /** + * @param array $body + * @return Request + */ + protected function createRequest(array $body): Request + { + $headers = [ + 'Content-type' => 'application/json', + ]; + $headers['Authorization'] = 'Bearer ' . trim($this->getCloudflareCacheProxy()->getBearer()); + $headers['X-Auth-Email'] = $this->getCloudflareCacheProxy()->getEmail(); + $headers['X-Auth-Key'] = $this->getCloudflareCacheProxy()->getKey(); + + $uri = sprintf( + 'https://api.cloudflare.com/client/%s/zones/%s/purge_cache', + $this->getCloudflareCacheProxy()->getVersion(), + $this->getCloudflareCacheProxy()->getZone() + ); + return new Request( + 'POST', + $uri, + $headers, + \json_encode($body) + ); + } + + /** + * @return Request + */ + protected function createBanRequest(): Request + { + return $this->createRequest([ + 'purge_everything' => true, + ]); + } + + /** + * @param string[] $uris + * + * @return Request + */ + protected function createPurgeRequest(array $uris = []): Request + { + return $this->createRequest([ + 'files' => $uris + ]); + } + + /** + * @param RequestInterface $request + * @return void + */ + protected function sendRequest(RequestInterface $request): void + { + try { + $this->bus->dispatch(new Envelope(new GuzzleRequestMessage($request, [ + 'debug' => false, + 'timeout' => $this->getCloudflareCacheProxy()->getTimeout() + ]))); + } catch (ExceptionInterface $exception) { + $this->logger->error($exception->getMessage()); + } + } +} diff --git a/lib/RoadizCoreBundle/src/EventSubscriber/DocumentTimestampSubscriber.php b/lib/RoadizCoreBundle/src/EventSubscriber/DocumentTimestampSubscriber.php new file mode 100644 index 00000000..54dac92d --- /dev/null +++ b/lib/RoadizCoreBundle/src/EventSubscriber/DocumentTimestampSubscriber.php @@ -0,0 +1,30 @@ + 'onDocumentTranslationUpdatedEvent' + ]; + } + + public function onDocumentTranslationUpdatedEvent(DocumentTranslationUpdatedEvent $event): void + { + $document = $event->getDocument(); + if ($document instanceof AbstractDateTimed) { + $document->setUpdatedAt(new \DateTime()); + } + } +} diff --git a/lib/RoadizCoreBundle/src/EventSubscriber/LocaleSubscriber.php b/lib/RoadizCoreBundle/src/EventSubscriber/LocaleSubscriber.php new file mode 100644 index 00000000..cfdd022b --- /dev/null +++ b/lib/RoadizCoreBundle/src/EventSubscriber/LocaleSubscriber.php @@ -0,0 +1,70 @@ +managerRegistry = $managerRegistry; + $this->router = $router; + } + + /** + * @return array + */ + public static function getSubscribedEvents(): array + { + return [ + // must be registered just after Symfony\Component\HttpKernel\EventListener\LocaleListener + RequestEvent::class => [['onKernelRequest', 16]], + ]; + } + + private function getDefaultTranslation(): ?TranslationInterface + { + return $this->managerRegistry->getRepository(Translation::class)->findDefault(); + } + + public function onKernelRequest(RequestEvent $event): void + { + $request = $event->getRequest(); + $locale = $request->query->get('_locale') ?? $request->attributes->get('_locale'); + + if ($request->hasPreviousSession()) { + $locale = $request->getSession()->get('_locale', null); + if (null !== $locale) { + $this->setLocale($event, $locale); + } + } + + /* + * Set default locale + */ + if (null !== $locale && $locale !== '') { + $this->setLocale($event, $locale); + } elseif (null !== $translation = $this->getDefaultTranslation()) { + $shortLocale = $translation->getLocale(); + $this->setLocale($event, $shortLocale); + } + } + + private function setLocale(RequestEvent $event, string $locale): void + { + $event->getRequest()->setLocale($locale); + \Locale::setDefault($locale); + $this->router->getContext()->setParameter('_locale', $locale); + } +} diff --git a/lib/RoadizCoreBundle/src/EventSubscriber/LoggableUsernameSubscriber.php b/lib/RoadizCoreBundle/src/EventSubscriber/LoggableUsernameSubscriber.php new file mode 100644 index 00000000..07ba3a48 --- /dev/null +++ b/lib/RoadizCoreBundle/src/EventSubscriber/LoggableUsernameSubscriber.php @@ -0,0 +1,59 @@ +tokenStorage = $tokenStorage; + $this->loggableListener = $loggableListener; + } + + /** + * @return array + */ + public static function getSubscribedEvents(): array + { + return [ + KernelEvents::REQUEST => ['onRequest', 33], + ]; + } + + /** + * @param RequestEvent $event + */ + public function onRequest(RequestEvent $event): void + { + if ($event->isMainRequest()) { + $token = $this->tokenStorage->getToken(); + if ($token && $token->getUsername() !== '') { + if ( + $this->loggableListener instanceof UserLoggableListener && + $token->getUser() instanceof User + ) { + $this->loggableListener->setUser($token->getUser()); + } else { + $this->loggableListener->setUsername($token->getUsername()); + } + } + } + } +} diff --git a/lib/RoadizCoreBundle/src/EventSubscriber/NodeDuplicationSubscriber.php b/lib/RoadizCoreBundle/src/EventSubscriber/NodeDuplicationSubscriber.php new file mode 100644 index 00000000..7370ee3d --- /dev/null +++ b/lib/RoadizCoreBundle/src/EventSubscriber/NodeDuplicationSubscriber.php @@ -0,0 +1,46 @@ +handlerFactory = $handlerFactory; + $this->managerRegistry = $managerRegistry; + } + + public static function getSubscribedEvents(): array + { + return [ + NodeDuplicatedEvent::class => 'cleanPosition', + '\RZ\Roadiz\Core\Events\Node\NodeDuplicatedEvent' => 'cleanPosition', + ]; + } + + public function cleanPosition(NodeDuplicatedEvent $event): void + { + /** @var NodeHandler $nodeHandler */ + $nodeHandler = $this->handlerFactory->getHandler($event->getNode()); + $nodeHandler->setNode($event->getNode()); + $nodeHandler->cleanChildrenPositions(); + $nodeHandler->cleanPositions(); + + $this->managerRegistry->getManager()->flush(); + } +} diff --git a/lib/RoadizCoreBundle/src/EventSubscriber/NodeNameSubscriber.php b/lib/RoadizCoreBundle/src/EventSubscriber/NodeNameSubscriber.php new file mode 100644 index 00000000..bad16cf7 --- /dev/null +++ b/lib/RoadizCoreBundle/src/EventSubscriber/NodeNameSubscriber.php @@ -0,0 +1,108 @@ +logger = $logger ?? new NullLogger(); + $this->nodeNamePolicy = $nodeNamePolicy; + $this->nodeMover = $nodeMover; + } + + /** + * @inheritDoc + */ + public static function getSubscribedEvents(): array + { + return [ + NodesSourcesPreUpdatedEvent::class => ['onBeforeUpdate', 0], + '\RZ\Roadiz\Core\Events\NodesSources\NodesSourcesPreUpdatedEvent' => ['onBeforeUpdate', 0], + ]; + } + + public function onBeforeUpdate( + NodesSourcesPreUpdatedEvent $event, + string $eventName, + EventDispatcherInterface $dispatcher + ): void { + $nodeSource = $event->getNodeSource(); + $title = $nodeSource->getTitle(); + + /* + * Update node name if dynamic option enabled and + * default translation + */ + if ( + "" != $title && + true === $nodeSource->getNode()->isDynamicNodeName() && + $nodeSource->getTranslation()->isDefaultTranslation() + ) { + $testingNodeName = $this->nodeNamePolicy->getCanonicalNodeName($nodeSource); + + /* + * Node name wont be updated if name already taken OR + * if it is ALREADY suffixed with a unique ID. + */ + if ( + $testingNodeName != $nodeSource->getNode()->getNodeName() && + $this->nodeNamePolicy->isNodeNameValid($testingNodeName) && + !$this->nodeNamePolicy->isNodeNameWithUniqId( + $testingNodeName, + $nodeSource->getNode()->getNodeName() + ) + ) { + try { + if ($nodeSource->isReachable()) { + $oldPaths = $this->nodeMover->getNodeSourcesUrls($nodeSource->getNode()); + $oldUpdateAt = $nodeSource->getNode()->getUpdatedAt(); + } + } catch (SameNodeUrlException $e) { + $oldPaths = []; + } + $alreadyUsed = $this->nodeNamePolicy->isNodeNameAlreadyUsed($testingNodeName); + if (!$alreadyUsed) { + $nodeSource->getNode()->setNodeName($testingNodeName); + } else { + $nodeSource->getNode()->setNodeName($this->nodeNamePolicy->getSafeNodeName($nodeSource)); + } + + /* + * Dispatch event + */ + if (isset($oldPaths) && isset($oldUpdateAt) && count($oldPaths) > 0) { + $dispatcher->dispatch(new NodePathChangedEvent($nodeSource->getNode(), $oldPaths, $oldUpdateAt)); + } + $dispatcher->dispatch(new NodeUpdatedEvent($nodeSource->getNode())); + } else { + $this->logger->debug('Node name has not be changed.'); + } + } + } +} diff --git a/lib/RoadizCoreBundle/src/EventSubscriber/NodeRedirectionSubscriber.php b/lib/RoadizCoreBundle/src/EventSubscriber/NodeRedirectionSubscriber.php new file mode 100644 index 00000000..31158fbb --- /dev/null +++ b/lib/RoadizCoreBundle/src/EventSubscriber/NodeRedirectionSubscriber.php @@ -0,0 +1,71 @@ +nodeMover = $nodeMover; + $this->kernel = $kernel; + $this->previewResolver = $previewResolver; + } + + public static function getSubscribedEvents(): array + { + return [ + NodePathChangedEvent::class => 'redirectOldPaths', + '\RZ\Roadiz\Core\Events\Node\NodePathChangedEvent' => 'redirectOldPaths' + ]; + } + + /** + * Empty nodeSources Url cache + * + * @param NodePathChangedEvent $event + * @param string $eventName + * @param EventDispatcherInterface $dispatcher + */ + public function redirectOldPaths( + NodePathChangedEvent $event, + string $eventName, + EventDispatcherInterface $dispatcher + ): void { + if ( + $this->kernel->getEnvironment() === 'prod' && + !$this->previewResolver->isPreview() && + null !== $event->getNode() && + $event->getNode()->isPublished() && + $event->getNode()->getNodeType()->isReachable() && + count($event->getPaths()) > 0 + ) { + $this->nodeMover->redirectAll($event->getNode(), $event->getPaths()); + $dispatcher->dispatch(new CachePurgeRequestEvent()); + } + } +} diff --git a/lib/RoadizCoreBundle/src/EventSubscriber/NodeSourcePathSubscriber.php b/lib/RoadizCoreBundle/src/EventSubscriber/NodeSourcePathSubscriber.php new file mode 100644 index 00000000..82dbaccf --- /dev/null +++ b/lib/RoadizCoreBundle/src/EventSubscriber/NodeSourcePathSubscriber.php @@ -0,0 +1,49 @@ +pathAggregator = $pathAggregator; + } + + /** + * @inheritDoc + */ + public static function getSubscribedEvents(): array + { + return [ + NodesSourcesPathGeneratingEvent::class => [['onNodesSourcesPath', -100]], + '\RZ\Roadiz\Core\Events\NodesSources\NodesSourcesPathGeneratingEvent' => [['onNodesSourcesPath', -100]], + ]; + } + + /** + * @param NodesSourcesPathGeneratingEvent $event + */ + public function onNodesSourcesPath(NodesSourcesPathGeneratingEvent $event): void + { + $urlGenerator = new NodesSourcesUrlGenerator( + $this->pathAggregator, + null, + $event->getNodeSource(), + $event->isForceLocale(), + $event->isForceLocaleWithUrlAlias() + ); + $event->setPath($urlGenerator->getNonContextualUrl($event->getTheme(), $event->getParameters())); + } +} diff --git a/lib/RoadizCoreBundle/src/EventSubscriber/NodesSourcesLinkHeaderEventSubscriber.php b/lib/RoadizCoreBundle/src/EventSubscriber/NodesSourcesLinkHeaderEventSubscriber.php new file mode 100644 index 00000000..a852c6eb --- /dev/null +++ b/lib/RoadizCoreBundle/src/EventSubscriber/NodesSourcesLinkHeaderEventSubscriber.php @@ -0,0 +1,72 @@ +managerRegistry = $managerRegistry; + $this->urlGenerator = $urlGenerator; + } + + /** + * @inheritDoc + */ + public static function getSubscribedEvents(): array + { + return [ + ViewEvent::class => ['onKernelView', 15] + ]; + } + + public function onKernelView(ViewEvent $event): void + { + $request = $event->getRequest(); + $resources = $request->attributes->get('data', null); + $linkProvider = $request->attributes->get('_links', new GenericLinkProvider()); + + if ($resources instanceof NodesSources && $linkProvider instanceof EvolvableLinkProviderInterface) { + /* + * Preview and authentication is handled at repository level. + */ + /** @var NodesSources[] $allSources */ + $allSources = $this->managerRegistry + ->getRepository(get_class($resources)) + ->findByNode($resources->getNode()); + + foreach ($allSources as $singleSource) { + $linkProvider = $linkProvider->withLink( + (new Link( + 'alternate', + $this->urlGenerator->generate(RouteObjectInterface::OBJECT_BASED_ROUTE_NAME, [ + RouteObjectInterface::ROUTE_OBJECT => $singleSource + ]) + )) + ->withAttribute('hreflang', $singleSource->getTranslation()->getLocale()) + // Must encode translation name in base64 because headers are ASCII only + ->withAttribute('title', \base64_encode($singleSource->getTranslation()->getName())) + ->withAttribute('type', 'text/html') + ); + } + $request->attributes->set('_links', $linkProvider); + } + } +} diff --git a/lib/RoadizCoreBundle/src/EventSubscriber/NodesSourcesUniversalSubscriber.php b/lib/RoadizCoreBundle/src/EventSubscriber/NodesSourcesUniversalSubscriber.php new file mode 100644 index 00000000..d13e80d6 --- /dev/null +++ b/lib/RoadizCoreBundle/src/EventSubscriber/NodesSourcesUniversalSubscriber.php @@ -0,0 +1,57 @@ +universalDataDuplicator = $universalDataDuplicator; + $this->managerRegistry = $managerRegistry; + } + + /** + * @return array + */ + public static function getSubscribedEvents(): array + { + return [ + NodesSourcesUpdatedEvent::class => 'duplicateUniversalContents', + '\RZ\Roadiz\Core\Events\NodesSources\NodesSourcesUpdatedEvent' => 'duplicateUniversalContents', + ]; + } + + /** + * @param NodesSourcesUpdatedEvent $event + * + * @throws ORMException + * @throws OptimisticLockException + */ + public function duplicateUniversalContents(NodesSourcesUpdatedEvent $event): void + { + $source = $event->getNodeSource(); + + /* + * Flush only if duplication happened. + */ + if (true === $this->universalDataDuplicator->duplicateUniversalContents($source)) { + $this->managerRegistry->getManager()->flush(); + } + } +} diff --git a/lib/RoadizCoreBundle/src/EventSubscriber/NodesSourcesUrlsCacheEventSubscriber.php b/lib/RoadizCoreBundle/src/EventSubscriber/NodesSourcesUrlsCacheEventSubscriber.php new file mode 100644 index 00000000..771eea51 --- /dev/null +++ b/lib/RoadizCoreBundle/src/EventSubscriber/NodesSourcesUrlsCacheEventSubscriber.php @@ -0,0 +1,82 @@ +cacheClearer = $cacheClearer; + } + + /** + * @inheritDoc + */ + public static function getSubscribedEvents(): array + { + return [ + NodesSourcesCreatedEvent::class => 'onPurgeRequest', + '\RZ\Roadiz\Core\Events\NodesSources\NodesSourcesCreatedEvent' => 'onPurgeRequest', + NodesSourcesDeletedEvent::class => 'onPurgeRequest', + '\RZ\Roadiz\Core\Events\NodesSources\NodesSourcesDeletedEvent' => 'onPurgeRequest', + TranslationUpdatedEvent::class => 'onPurgeRequest', + '\RZ\Roadiz\Core\Events\Translation\TranslationUpdatedEvent' => 'onPurgeRequest', + TranslationDeletedEvent::class => 'onPurgeRequest', + '\RZ\Roadiz\Core\Events\Translation\TranslationDeletedEvent' => 'onPurgeRequest', + NodeDeletedEvent::class => 'onPurgeRequest', + '\RZ\Roadiz\Core\Events\Node\NodeDeletedEvent' => 'onPurgeRequest', + NodeUndeletedEvent::class => 'onPurgeRequest', + '\RZ\Roadiz\Core\Events\Node\NodeUndeletedEvent' => 'onPurgeRequest', + NodeUpdatedEvent::class => 'onPurgeRequest', + '\RZ\Roadiz\Core\Events\Node\NodeUpdatedEvent' => 'onPurgeRequest', + UrlAliasCreatedEvent::class => 'onPurgeRequest', + '\RZ\Roadiz\Core\Events\UrlAlias\UrlAliasCreatedEvent' => 'onPurgeRequest', + UrlAliasUpdatedEvent::class => 'onPurgeRequest', + '\RZ\Roadiz\Core\Events\UrlAlias\UrlAliasUpdatedEvent' => 'onPurgeRequest', + UrlAliasDeletedEvent::class => 'onPurgeRequest', + '\RZ\Roadiz\Core\Events\UrlAlias\UrlAliasDeletedEvent' => 'onPurgeRequest', + 'workflow.node.completed' => 'onPurgeRequest', + CachePurgeRequestEvent::class => ['onPurgeRequest', 3], + '\RZ\Roadiz\Core\Events\Cache\CachePurgeRequestEvent' => ['onPurgeRequest', 3], + ]; + } + + /** + * @param CachePurgeRequestEvent|mixed $event + */ + public function onPurgeRequest(mixed $event): void + { + try { + if (false !== $this->cacheClearer->clear()) { + if ($event instanceof CachePurgeRequestEvent) { + $event->addMessage($this->cacheClearer->getOutput(), self::class, 'NodesSources URL cache'); + } + } + } catch (\Exception $e) { + if ($event instanceof CachePurgeRequestEvent) { + $event->addError($e->getMessage(), self::class, 'NodesSources URL cache'); + } + } + } +} diff --git a/lib/RoadizCoreBundle/src/EventSubscriber/OPCacheEventSubscriber.php b/lib/RoadizCoreBundle/src/EventSubscriber/OPCacheEventSubscriber.php new file mode 100644 index 00000000..6ab73e53 --- /dev/null +++ b/lib/RoadizCoreBundle/src/EventSubscriber/OPCacheEventSubscriber.php @@ -0,0 +1,38 @@ + ['onPurgeRequest', 3], + '\RZ\Roadiz\Core\Events\Cache\CachePurgeRequestEvent' => ['onPurgeRequest', 3], + ]; + } + + /** + * @param CachePurgeRequestEvent $event + */ + public function onPurgeRequest(CachePurgeRequestEvent $event): void + { + try { + $clearer = new OPCacheClearer(); + if (false !== $clearer->clear()) { + $event->addMessage($clearer->getOutput(), self::class, 'OPCode cache'); + } + } catch (\Exception $e) { + $event->addError($e->getMessage(), self::class, 'OPCode cache'); + } + } +} diff --git a/lib/RoadizCoreBundle/src/EventSubscriber/RealmNodeInheritanceSubscriber.php b/lib/RoadizCoreBundle/src/EventSubscriber/RealmNodeInheritanceSubscriber.php new file mode 100644 index 00000000..b18da0fa --- /dev/null +++ b/lib/RoadizCoreBundle/src/EventSubscriber/RealmNodeInheritanceSubscriber.php @@ -0,0 +1,76 @@ +bus = $bus; + } + + /** + * @inheritDoc + */ + public static function getSubscribedEvents(): array + { + return [ + NodeJoinedRealmEvent::class => 'onNodeJoinedRealm', + NodeLeftRealmEvent::class => 'onNodeLeftRealm', + NodeUpdatedEvent::class => 'onNodeUpdated', + NodeCreatedEvent::class => 'onNodeUpdated', + ]; + } + + public function onNodeUpdated(FilterNodeEvent $event): void + { + /* + * Do not store objects in async operations to avoid issues with Doctrine Object manager + */ + $this->bus->dispatch(new Envelope(new SearchRealmNodeInheritanceMessage( + $event->getNode()->getId() + ))); + } + + public function onNodeJoinedRealm(AbstractRealmNodeEvent $event): void + { + /* + * Do not store objects in async operations to avoid issues with Doctrine Object manager + */ + $this->bus->dispatch(new Envelope(new ApplyRealmNodeInheritanceMessage( + $event->getRealmNode()->getNode()->getId(), + $event->getRealmNode()->getRealm()?->getId() + ))); + } + + public function onNodeLeftRealm(AbstractRealmNodeEvent $event): void + { + /* + * Do not store objects in async operations to avoid issues with Doctrine Object manager + */ + $this->bus->dispatch(new Envelope(new CleanRealmNodeInheritanceMessage( + $event->getRealmNode()->getNode()->getId(), + $event->getRealmNode()->getRealm()?->getId() + ))); + } +} diff --git a/lib/RoadizCoreBundle/src/EventSubscriber/ReverseProxyCacheEventSubscriber.php b/lib/RoadizCoreBundle/src/EventSubscriber/ReverseProxyCacheEventSubscriber.php new file mode 100644 index 00000000..eec69e0c --- /dev/null +++ b/lib/RoadizCoreBundle/src/EventSubscriber/ReverseProxyCacheEventSubscriber.php @@ -0,0 +1,156 @@ +logger = $logger; + $this->bus = $bus; + $this->reverseProxyCacheLocator = $reverseProxyCacheLocator; + } + /** + * @inheritDoc + */ + public static function getSubscribedEvents(): array + { + return [ + CachePurgeRequestEvent::class => ['onBanRequest', 3], + '\RZ\Roadiz\Core\Events\Cache\CachePurgeRequestEvent' => ['onBanRequest', 3], + NodesSourcesUpdatedEvent::class => ['onPurgeRequest', 3], + '\RZ\Roadiz\Core\Events\NodesSources\NodesSourcesUpdatedEvent' => ['onPurgeRequest', 3], + 'workflow.node.completed' => ['onNodeWorkflowCompleted', 3], + ]; + } + + /** + * @return bool + */ + protected function supportConfig(): bool + { + return count($this->reverseProxyCacheLocator->getFrontends()) > 0; + } + + /** + * @param Event $event + */ + public function onNodeWorkflowCompleted(Event $event): void + { + $node = $event->getSubject(); + if ($node instanceof Node) { + if (!$this->supportConfig()) { + return; + } + foreach ($node->getNodeSources() as $nodeSource) { + $this->purgeNodesSources($nodeSource); + } + } + } + + /** + * @param CachePurgeRequestEvent $event + */ + public function onBanRequest(CachePurgeRequestEvent $event): void + { + if (!$this->supportConfig()) { + return; + } + + foreach ($this->createBanRequests() as $name => $request) { + $this->sendRequest($request); + $event->addMessage( + 'Reverse proxy cache cleared.', + self::class, + 'Reverse proxy cache [' . $name . ']' + ); + } + } + + /** + * @param NodesSourcesUpdatedEvent $event + */ + public function onPurgeRequest(NodesSourcesUpdatedEvent $event): void + { + if (!$this->supportConfig()) { + return; + } + + $this->purgeNodesSources($event->getNodeSource()); + } + + /** + * @return Request[] + */ + protected function createBanRequests(): array + { + $requests = []; + foreach ($this->reverseProxyCacheLocator->getFrontends() as $frontend) { + $requests[$frontend->getName()] = new Request( + 'BAN', + 'http://' . $frontend->getHost(), + [ + 'Host' => $frontend->getDomainName() + ] + ); + } + return $requests; + } + + /** + * @param NodesSources $nodeSource + */ + protected function purgeNodesSources(NodesSources $nodeSource): void + { + try { + $this->bus->dispatch(new Envelope(new PurgeReverseProxyCacheMessage($nodeSource->getId()))); + } catch (ExceptionInterface $exception) { + $this->logger->error($exception->getMessage()); + } + } + + /** + * @param Request $request + * @return void + */ + protected function sendRequest(Request $request): void + { + try { + $this->bus->dispatch(new Envelope(new GuzzleRequestMessage($request, [ + 'debug' => false, + 'timeout' => 3 + ]))); + } catch (ExceptionInterface $exception) { + $this->logger->error($exception->getMessage()); + } + } +} diff --git a/lib/RoadizCoreBundle/src/EventSubscriber/RoleSubscriber.php b/lib/RoadizCoreBundle/src/EventSubscriber/RoleSubscriber.php new file mode 100644 index 00000000..d04eb2ab --- /dev/null +++ b/lib/RoadizCoreBundle/src/EventSubscriber/RoleSubscriber.php @@ -0,0 +1,61 @@ +roles = $roles; + $this->managerRegistry = $managerRegistry; + } + + /** + * @inheritDoc + */ + public static function getSubscribedEvents(): array + { + return [ + PreCreatedRoleEvent::class => 'onRoleChanged', + '\RZ\Roadiz\Core\Events\Role\PreCreatedRoleEvent' => 'onRoleChanged', + PreUpdatedRoleEvent::class => 'onRoleChanged', + '\RZ\Roadiz\Core\Events\Role\PreUpdatedRoleEvent' => 'onRoleChanged', + PreDeletedRoleEvent::class => 'onRoleChanged', + '\RZ\Roadiz\Core\Events\Role\PreDeletedRoleEvent' => 'onRoleChanged', + ]; + } + + public function onRoleChanged(RoleEvent $event): void + { + $manager = $this->managerRegistry->getManagerForClass(Role::class); + // Clear result cache + if ( + $manager instanceof EntityManagerInterface && + $manager->getConfiguration()->getResultCacheImpl() instanceof CacheProvider + ) { + $manager->getConfiguration()->getResultCacheImpl()->deleteAll(); + } + // Clear memory roles bag + $this->roles?->reset(); + } +} diff --git a/lib/RoadizCoreBundle/src/EventSubscriber/SignatureSubscriber.php b/lib/RoadizCoreBundle/src/EventSubscriber/SignatureSubscriber.php new file mode 100644 index 00000000..518e73b1 --- /dev/null +++ b/lib/RoadizCoreBundle/src/EventSubscriber/SignatureSubscriber.php @@ -0,0 +1,48 @@ +version = $cmsVersion; + $this->debug = $debug; + $this->hideRoadizVersion = $hideRoadizVersion; + } + /** + * Filters the Response. + * + * @param ResponseEvent $event A ResponseEvent instance + */ + public function onKernelResponse(ResponseEvent $event): void + { + if (!$event->isMainRequest() || $this->hideRoadizVersion) { + return; + } + + $response = $event->getResponse(); + $response->headers->add(['X-Powered-By' => 'Roadiz CMS']); + + if ($this->debug && $this->version) { + $response->headers->add(['X-Version' => $this->version]); + } + } + + public static function getSubscribedEvents(): array + { + return [ + KernelEvents::RESPONSE => 'onKernelResponse', + ]; + } +} diff --git a/lib/RoadizCoreBundle/src/EventSubscriber/TagTimestampSubscriber.php b/lib/RoadizCoreBundle/src/EventSubscriber/TagTimestampSubscriber.php new file mode 100644 index 00000000..ca59a717 --- /dev/null +++ b/lib/RoadizCoreBundle/src/EventSubscriber/TagTimestampSubscriber.php @@ -0,0 +1,30 @@ + 'onTagUpdatedEvent' + ]; + } + + public function onTagUpdatedEvent(TagUpdatedEvent $event): void + { + $tag = $event->getTag(); + if ($tag instanceof AbstractDateTimed) { + $tag->setUpdatedAt(new \DateTime()); + } + } +} diff --git a/lib/RoadizCoreBundle/src/EventSubscriber/TranslationSubscriber.php b/lib/RoadizCoreBundle/src/EventSubscriber/TranslationSubscriber.php new file mode 100644 index 00000000..e7f61509 --- /dev/null +++ b/lib/RoadizCoreBundle/src/EventSubscriber/TranslationSubscriber.php @@ -0,0 +1,57 @@ +managerRegistry = $managerRegistry; + } + + public static function getSubscribedEvents(): array + { + return [ + TranslationCreatedEvent::class => 'purgeCache', + '\RZ\Roadiz\Core\Events\Translation\TranslationCreatedEvent' => 'purgeCache', + TranslationUpdatedEvent::class => 'purgeCache', + '\RZ\Roadiz\Core\Events\Translation\TranslationUpdatedEvent' => 'purgeCache', + TranslationDeletedEvent::class => 'purgeCache', + '\RZ\Roadiz\Core\Events\Translation\TranslationDeletedEvent' => 'purgeCache', + ]; + } + + /** + * Empty nodeSources Url cache + */ + public function purgeCache(Event $event, string $eventName, EventDispatcherInterface $dispatcher): void + { + $manager = $this->managerRegistry->getManager(); + // Clear result cache + if ( + $manager instanceof EntityManagerInterface && + $manager->getConfiguration()->getResultCacheImpl() instanceof CacheProvider + ) { + $manager->getConfiguration()->getResultCacheImpl()->deleteAll(); + } + $dispatcher->dispatch(new CachePurgeRequestEvent()); + } +} diff --git a/lib/RoadizCoreBundle/src/EventSubscriber/UserLocaleSubscriber.php b/lib/RoadizCoreBundle/src/EventSubscriber/UserLocaleSubscriber.php new file mode 100644 index 00000000..fc75e89f --- /dev/null +++ b/lib/RoadizCoreBundle/src/EventSubscriber/UserLocaleSubscriber.php @@ -0,0 +1,76 @@ +requestStack = $requestStack; + $this->tokenStorage = $tokenStorage; + } + + /** + * @return array + */ + public static function getSubscribedEvents(): array + { + // must be registered after the default Locale listener + return [ + SecurityEvents::INTERACTIVE_LOGIN => 'onInteractiveLogin', + UserUpdatedEvent::class => [['onUserUpdated']], + '\RZ\Roadiz\Core\Events\User\UserUpdatedEvent' => [['onUserUpdated']], + ]; + } + + /** + * @param InteractiveLoginEvent $event + */ + public function onInteractiveLogin(InteractiveLoginEvent $event): void + { + $user = $event->getAuthenticationToken()->getUser(); + + if ( + $user instanceof User && + null !== $user->getLocale() + ) { + $this->requestStack->getSession()->set('_locale', $user->getLocale()); + } + } + + /** + * @param FilterUserEvent $event + */ + public function onUserUpdated(FilterUserEvent $event): void + { + $user = $event->getUser(); + + if ( + null !== $this->tokenStorage->getToken() && + $this->tokenStorage->getToken()->getUser() instanceof User && + $this->tokenStorage->getToken()->getUsername() === $user->getUsername() + ) { + if (null === $user->getLocale()) { + $this->requestStack->getSession()->remove('_locale'); + } else { + $this->requestStack->getSession()->set('_locale', $user->getLocale()); + } + } + } +} diff --git a/lib/RoadizCoreBundle/src/Exception/BadFormRequestException.php b/lib/RoadizCoreBundle/src/Exception/BadFormRequestException.php new file mode 100644 index 00000000..c0ba063e --- /dev/null +++ b/lib/RoadizCoreBundle/src/Exception/BadFormRequestException.php @@ -0,0 +1,38 @@ +statusText = $statusText; + $this->fieldErrored = $fieldErrored; + } + + public function getStatusText(): string + { + return $this->statusText; + } + + public function getFieldErrored(): ?string + { + return $this->fieldErrored; + } +} diff --git a/lib/RoadizCoreBundle/src/Exception/EmbedPlatformNotSupportedException.php b/lib/RoadizCoreBundle/src/Exception/EmbedPlatformNotSupportedException.php new file mode 100644 index 00000000..7782198d --- /dev/null +++ b/lib/RoadizCoreBundle/src/Exception/EmbedPlatformNotSupportedException.php @@ -0,0 +1,12 @@ +foreground_colors['black'] = '0;30'; + $this->foreground_colors['dark_gray'] = '1;30'; + $this->foreground_colors['blue'] = '0;34'; + $this->foreground_colors['light_blue'] = '1;34'; + $this->foreground_colors['green'] = '0;32'; + $this->foreground_colors['light_green'] = '1;32'; + $this->foreground_colors['cyan'] = '0;36'; + $this->foreground_colors['light_cyan'] = '1;36'; + $this->foreground_colors['red'] = '0;31'; + $this->foreground_colors['light_red'] = '1;31'; + $this->foreground_colors['purple'] = '0;35'; + $this->foreground_colors['light_purple'] = '1;35'; + $this->foreground_colors['brown'] = '0;33'; + $this->foreground_colors['yellow'] = '1;33'; + $this->foreground_colors['light_gray'] = '0;37'; + $this->foreground_colors['white'] = '1;37'; + + $this->background_colors['black'] = '40'; + $this->background_colors['red'] = '41'; + $this->background_colors['green'] = '42'; + $this->background_colors['yellow'] = '43'; + $this->background_colors['blue'] = '44'; + $this->background_colors['magenta'] = '45'; + $this->background_colors['cyan'] = '46'; + $this->background_colors['light_gray'] = '47'; + } + + /** + * @param \Exception|\TypeError $exception + * @return int + */ + public function getHttpStatusCode($exception): int + { + if ($exception instanceof HttpExceptionInterface) { + return $exception->getStatusCode(); + } elseif ($exception instanceof ResourceNotFoundException) { + return Response::HTTP_NOT_FOUND; + } elseif ($exception instanceof MaintenanceModeException) { + return Response::HTTP_SERVICE_UNAVAILABLE; + } elseif ($exception instanceof AccessDeniedException || $exception instanceof AccessDeniedHttpException) { + return Response::HTTP_FORBIDDEN; + } + + return Response::HTTP_INTERNAL_SERVER_ERROR; + } + + /** + * @param \Exception|\TypeError $e + * @return string + */ + public function getHumanExceptionTitle($e): string + { + if ($e instanceof MaintenanceModeException) { + return "Website is under maintenance."; + } + + if ($e instanceof NoConfigurationFoundException) { + return "No configuration file has been found. Did you run composer install before using Roadiz?"; + } + + if ($e instanceof InvalidConfigurationException) { + return "Roadiz configuration is not valid."; + } + + if ($e instanceof ResourceNotFoundException || $e instanceof NotFoundHttpException) { + return "Resource not found."; + } + + if ($e instanceof ConnectionException || $e instanceof \Doctrine\DBAL\ConnectionException) { + return "Your database is not reachable. Did you run install before using Roadiz?"; + } + + if ($e instanceof TableNotFoundException) { + return "Your database is not synchronised to Roadiz data schema. Did you run install before using Roadiz?"; + } + + if ($e instanceof AccessDeniedException || $e instanceof AccessDeniedHttpException) { + return "Oups! Wrong way, you are not supposed to be here."; + } + + return "A problem occurred on our website. We are working on this to be back soon."; + } + + /** + * @param \Exception|\TypeError $e + * @return string + */ + public function getJsonError($e): string + { + if ($e instanceof NoConfigurationFoundException) { + return "no_configuration_file"; + } + + if ($e instanceof InvalidConfigurationException) { + return "invalid_configuration"; + } + + if ($e instanceof ResourceNotFoundException || $e instanceof NotFoundHttpException) { + return "not_found"; + } + + if ($e instanceof ConnectionException || $e instanceof \Doctrine\DBAL\ConnectionException) { + return "database_not_reachable"; + } + + if ($e instanceof TableNotFoundException) { + return "database_not_uptodate"; + } + + if ($e instanceof AccessDeniedException || $e instanceof AccessDeniedHttpException) { + return "access_denied"; + } + + return "general_error"; + } + + + /** + * @param \Exception|\TypeError $e + * @param Request $request + * @param bool $debug + * @return JsonResponse|Response + */ + public function getResponse($e, Request $request, bool $debug = false): Response + { + /* + * Log error before displaying a fallback page. + */ + $class = get_class($e); + + $humanMessage = $this->getHumanExceptionTitle($e); + + if (php_sapi_name() === 'cli') { + return new Response( + implode(PHP_EOL, [ + $this->getColoredString('[' . $class . ']', 'white', 'red'), + $this->getColoredString($e->getMessage(), 'red', null), + ]) . PHP_EOL, + $this->getHttpStatusCode($e), + [ + 'content-type' => 'text/plain', + ] + ); + } elseif ($this->isFormatJson($request)) { + $data = [ + 'error' => $this->getJsonError($e), + 'error_message' => $e->getMessage(), + 'message' => $e->getMessage(), + 'exception' => $class, + 'humanMessage' => $humanMessage, + 'status' => $this->getHttpStatusCode($e), + ]; + if ($debug) { + $data['error_trace'] = $e->getTrace(); + } + return new JsonResponse($data, $this->getHttpStatusCode($e)); + } else { + $html = file_get_contents(dirname(__DIR__) . '/../templates/emerg.html'); + $html = str_replace('{{ http_code }}', (string) $this->getHttpStatusCode($e), $html); + $html = str_replace('{{ human_message }}', $humanMessage, $html); + + if ($e instanceof MaintenanceModeException) { + $html = str_replace('{{ smiley }}', '🏗', $html); + } elseif ($this->getHttpStatusCode($e) === Response::HTTP_FORBIDDEN) { + $html = str_replace('{{ smiley }}', '🤔', $html); + } elseif ($this->getHttpStatusCode($e) === Response::HTTP_NOT_FOUND) { + $html = str_replace('{{ smiley }}', '🧐', $html); + } else { + $html = str_replace('{{ smiley }}', '🤕', $html); + } + + if ($debug) { + $html = str_replace('{{ message }}', $e->getMessage(), $html); + $trace = preg_replace('#([^\n]+)#', '

$1

', $e->getTraceAsString()); + $trace = $this->addTwigSource($e, $trace); + $html = str_replace('{{ details }}', $trace, $html); + $html = str_replace('{{ exception }}', $class, $html); + } else { + $html = str_replace('{{ message }}', '', $html); + $html = str_replace('{{ details }}', '', $html); + $html = str_replace('{{ exception }}', '', $html); + } + + return new Response( + $html, + $this->getHttpStatusCode($e), + [ + 'content-type' => 'text/html', + 'X-Error-Reason' => $e->getMessage(), + ] + ); + } + } + + /** + * @param \Exception|\TypeError $e + * @param string $trace + * + * @return string + */ + protected function addTwigSource($e, string $trace): string + { + if ($e instanceof SyntaxError && null !== $e->getSourceContext()) { + return '' . PHP_EOL . + '' . PHP_EOL . + '
Template' . $e->getSourceContext()->getName() . '
Line number' . $e->getTemplateLine() . '
Path' . $e->getSourceContext()->getPath() . '
' . PHP_EOL . + $trace; + } elseif ($e instanceof Error && null !== $e->getSourceContext()) { + return '' . PHP_EOL . + '
Template' . $e->getSourceContext()->getName() . '
' . $e->getSourceContext()->getPath() . '
' . PHP_EOL . + $trace; + } + return $trace; + } + + /** + * @param Request $request + * @return bool + */ + public function isFormatJson(Request $request): bool + { + if ( + $request->attributes->has('_format') && + ( + $request->attributes->get('_format') == 'json' || + $request->attributes->get('_format') == 'ld+json' + ) + ) { + return true; + } + + if ( + $request->headers->get('Content-Type') && + ( + 0 === strpos($request->headers->get('Content-Type'), 'application/json') || + 0 === strpos($request->headers->get('Content-Type'), 'application/ld+json') + ) + ) { + return true; + } + + if ( + in_array('application/json', $request->getAcceptableContentTypes()) || + in_array('application/ld+json', $request->getAcceptableContentTypes()) + ) { + return true; + } + + return false; + } + + /** + * @param string $string + * @param string|null $foreground_color + * @param string|null $background_color + * @return string + */ + public function getColoredString(string $string, $foreground_color = null, $background_color = null): string + { + $colored_string = ""; + + // Check if given foreground color found + if (isset($this->foreground_colors[$foreground_color])) { + $colored_string .= "\033[" . $this->foreground_colors[$foreground_color] . "m"; + } + // Check if given background color found + if (isset($this->background_colors[$background_color])) { + $colored_string .= "\033[" . $this->background_colors[$background_color] . "m"; + } + + // Add string and end coloring + $colored_string .= $string . "\033[0m"; + + return $colored_string; + } + + /** + * @return array Returns all foreground color names + */ + public function getForegroundColors(): array + { + return array_keys($this->foreground_colors); + } + + /** + * @return array Returns all background color names + */ + public function getBackgroundColors(): array + { + return array_keys($this->background_colors); + } +} diff --git a/lib/RoadizCoreBundle/src/Exception/FacebookUsernameNotFoundException.php b/lib/RoadizCoreBundle/src/Exception/FacebookUsernameNotFoundException.php new file mode 100644 index 00000000..bb33f0af --- /dev/null +++ b/lib/RoadizCoreBundle/src/Exception/FacebookUsernameNotFoundException.php @@ -0,0 +1,12 @@ +response = $response; + } + + /** + * Gets the value of response. + * + * @return Response + */ + public function getResponse(): Response + { + return $this->response; + } + + /** + * Sets the value of response. + * + * @param Response $response the response + * + * @return self + */ + public function setResponse(Response $response) + { + $this->response = $response; + + return $this; + } +} diff --git a/lib/RoadizCoreBundle/src/Exception/MaintenanceModeException.php b/lib/RoadizCoreBundle/src/Exception/MaintenanceModeException.php new file mode 100644 index 00000000..bbb4d9ab --- /dev/null +++ b/lib/RoadizCoreBundle/src/Exception/MaintenanceModeException.php @@ -0,0 +1,41 @@ +controller; + } + + /** + * @var string + */ + protected $message = 'Website is currently under maintenance. We will be back shortly.'; + + /** + * @param AbstractController|null $controller + * @param string $message + * @param int $code + */ + public function __construct(AbstractController $controller = null, $message = null, $code = 0) + { + if (null !== $message) { + parent::__construct($message, $code); + } else { + parent::__construct($this->message, $code); + } + + $this->controller = $controller; + } +} diff --git a/lib/RoadizCoreBundle/src/Exception/NoConfigurationFoundException.php b/lib/RoadizCoreBundle/src/Exception/NoConfigurationFoundException.php new file mode 100644 index 00000000..47753fc5 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Exception/NoConfigurationFoundException.php @@ -0,0 +1,18 @@ +managerRegistry = $managerRegistry; + $this->requestStack = $requestStack; + $this->urlGenerator = $urlGenerator; + } + + /** + * @return class-string + */ + abstract protected function getProvidedClassname(): string; + + /** + * @return array + */ + abstract protected function getDefaultCriteria(): array; + + /** + * @return array + */ + abstract protected function getDefaultOrdering(): array; + + /** + * @param array $options + * + * @return EntityListManagerInterface + */ + protected function doFetchItems(array $options = []): EntityListManagerInterface + { + $resolver = new OptionsResolver(); + $this->configureOptions($resolver); + $this->options = $resolver->resolve($options); + + $listManager = new EntityListManager( + $this->requestStack->getCurrentRequest(), + $this->managerRegistry->getManager(), + $this->getProvidedClassname(), + $this->getDefaultCriteria(), + $this->getDefaultOrdering() + ); + $listManager->setDisplayingNotPublishedNodes(true); + $listManager->setItemPerPage($this->options['itemPerPage']); + $listManager->handle(); + $listManager->setPage((int) $this->options['page']); + + return $listManager; + } + /** + * @inheritDoc + */ + public function getItems($options = []): array + { + $listManager = $this->doFetchItems($options); + + $items = []; + foreach ($listManager->getEntities() as $entity) { + $items[] = $this->toExplorerItem($entity); + } + + return $items; + } + + /** + * @inheritDoc + */ + public function getFilters($options = []): array + { + $listManager = $this->doFetchItems($options); + + return $listManager->getAssignation(); + } + + /** + * @inheritDoc + */ + public function getItemsById(array $ids = []): array + { + if (is_array($ids) && count($ids) > 0) { + $entities = $this->managerRegistry->getRepository($this->getProvidedClassname())->findBy([ + 'id' => $ids + ]); + + /* + * Sort entities the same way IDs were given + */ + usort($entities, function ($a, $b) use ($ids) { + return array_search($a->getId(), $ids) <=> array_search($b->getId(), $ids); + }); + + $items = []; + foreach ($entities as $entity) { + $items[] = $this->toExplorerItem($entity); + } + + return $items; + } + + return []; + } +} diff --git a/lib/RoadizCoreBundle/src/Explorer/AbstractExplorerItem.php b/lib/RoadizCoreBundle/src/Explorer/AbstractExplorerItem.php new file mode 100644 index 00000000..dbfd3efd --- /dev/null +++ b/lib/RoadizCoreBundle/src/Explorer/AbstractExplorerItem.php @@ -0,0 +1,32 @@ + $this->getId(), + 'classname' => $this->getAlternativeDisplayable() ?? '', + 'displayable' => $this->getDisplayable(), + 'editItem' => $this->getEditItemPath(), + 'thumbnail' => $this->getThumbnail() + ]; + } +} diff --git a/lib/RoadizCoreBundle/src/Explorer/AbstractExplorerProvider.php b/lib/RoadizCoreBundle/src/Explorer/AbstractExplorerProvider.php new file mode 100644 index 00000000..14dc0936 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Explorer/AbstractExplorerProvider.php @@ -0,0 +1,32 @@ +container = $container; + return $this; + } + + /** + * @param OptionsResolver $resolver + */ + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'page' => 1, + 'search' => null, + 'itemPerPage' => 30 + ]); + } +} diff --git a/lib/RoadizCoreBundle/src/Explorer/ExplorerItemInterface.php b/lib/RoadizCoreBundle/src/Explorer/ExplorerItemInterface.php new file mode 100644 index 00000000..373c8bb7 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Explorer/ExplorerItemInterface.php @@ -0,0 +1,37 @@ + $ids + * @return ExplorerItemInterface[] + */ + public function getItemsById(array $ids = []): array; + + /** + * Check if object can be handled be current ExplorerProvider. + * + * @param mixed $item + * @return bool + */ + public function supports(mixed $item): bool; +} diff --git a/lib/RoadizCoreBundle/src/Filesystem/RoadizFileDirectories.php b/lib/RoadizCoreBundle/src/Filesystem/RoadizFileDirectories.php new file mode 100644 index 00000000..691e2f35 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Filesystem/RoadizFileDirectories.php @@ -0,0 +1,60 @@ +projectDir = $projectDir; + } + + public function getPublicFilesPath(): string + { + return $this->projectDir . '/public' . $this->getPublicFilesBasePath(); + } + + public function getPublicFilesBasePath(): string + { + return '/files'; + } + + public function getPrivateFilesPath(): string + { + return $this->projectDir . '/var' . $this->getPrivateFilesBasePath(); + } + + public function getPrivateFilesBasePath(): string + { + return '/files/private'; + } + + public function getFontsFilesPath(): string + { + return $this->projectDir . '/var' . $this->getFontsFilesBasePath(); + } + + public function getFontsFilesBasePath(): string + { + return '/files/fonts'; + } + + public function getPublicCachePath(): string + { + return $this->projectDir . '/public' . $this->getPublicCacheBasePath(); + } + + public function getPublicCacheBasePath(): string + { + return '/assets'; + } +} diff --git a/lib/RoadizCoreBundle/src/Form/AttributeChoiceType.php b/lib/RoadizCoreBundle/src/Form/AttributeChoiceType.php new file mode 100644 index 00000000..3c5d11bd --- /dev/null +++ b/lib/RoadizCoreBundle/src/Form/AttributeChoiceType.php @@ -0,0 +1,76 @@ +addModelTransformer(new CallbackTransformer( + function ($dataToForm) { + if ($dataToForm instanceof Attribute) { + return $dataToForm->getId(); + } + return null; + }, + function ($formToData) use ($options) { + return $options['entityManager']->find(Attribute::class, $formToData); + } + )); + } + + /** + * @inheritDoc + */ + public function configureOptions(OptionsResolver $resolver): void + { + parent::configureOptions($resolver); + $resolver->setDefault('empty_data', null); + $resolver->setRequired('entityManager'); + $resolver->setAllowedTypes('entityManager', [EntityManagerInterface::class]); + $resolver->setRequired('translation'); + $resolver->setAllowedTypes('translation', [Translation::class]); + $resolver->setNormalizer('choices', function (Options $options) { + $choices = []; + /** @var Attribute[] $attributes */ + $attributes = $options['entityManager']->getRepository(Attribute::class)->findBy( + [], + ['code' => 'ASC'] + ); + foreach ($attributes as $attribute) { + if (null !== $attribute->getGroup()) { + if (!isset($choices[$attribute->getGroup()->getName()])) { + $choices[$attribute->getGroup()->getName()] = []; + } + $choices[$attribute->getGroup()->getName()][$attribute->getLabelOrCode($options['translation'])] = $attribute->getId(); + } else { + $choices[$attribute->getLabelOrCode($options['translation'])] = $attribute->getId(); + } + } + return $choices; + }); + } + + /** + * @inheritDoc + */ + public function getParent(): ?string + { + return ChoiceType::class; + } +} diff --git a/lib/RoadizCoreBundle/src/Form/AttributeDocumentType.php b/lib/RoadizCoreBundle/src/Form/AttributeDocumentType.php new file mode 100644 index 00000000..f361f14c --- /dev/null +++ b/lib/RoadizCoreBundle/src/Form/AttributeDocumentType.php @@ -0,0 +1,100 @@ +entityManager = $entityManager; + } + + /** + * @param FormBuilderInterface $builder + * @param array $options + */ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder->addEventListener( + FormEvents::POST_SUBMIT, + [$this, 'onPostSubmit'] + ); + $builder->addModelTransformer(new AttributeDocumentsTransformer( + $this->entityManager, + $options['attribute'] + )); + } + + /** + * @param OptionsResolver $resolver + */ + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'required' => false, + 'allow_add' => true, + 'allow_delete' => true, + 'class' => AttributeDocuments::class, + ]); + + $resolver->setRequired('attribute'); + $resolver->setAllowedTypes('attribute', [AttributeInterface::class]); + } + + /** + * {@inheritdoc} + */ + public function getBlockPrefix(): string + { + return 'documents'; + } + + /** + * @inheritDoc + */ + public function getParent(): ?string + { + return CollectionType::class; + } + + /** + * Delete existing document association. + * + * @param FormEvent $event + */ + public function onPostSubmit(FormEvent $event): void + { + if ($event->getForm()->getConfig()->getOption('attribute') instanceof AttributeInterface) { + /** @var AttributeInterface $attribute */ + $attribute = $event->getForm()->getConfig()->getOption('attribute'); + + if ($attribute instanceof Attribute && $attribute->getId()) { + $qb = $this->entityManager->getRepository(AttributeDocuments::class) + ->createQueryBuilder('ad'); + $qb->delete() + ->andWhere($qb->expr()->eq('ad.attribute', ':attribute')) + ->setParameter(':attribute', $attribute); + $qb->getQuery()->execute(); + } + } + } +} diff --git a/lib/RoadizCoreBundle/src/Form/AttributeGroupTranslationType.php b/lib/RoadizCoreBundle/src/Form/AttributeGroupTranslationType.php new file mode 100644 index 00000000..d5de4b4b --- /dev/null +++ b/lib/RoadizCoreBundle/src/Form/AttributeGroupTranslationType.php @@ -0,0 +1,66 @@ +managerRegistry = $managerRegistry; + } + + /** + * @inheritDoc + */ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder->add('name', TextType::class, [ + 'empty_data' => '', + 'label' => false, + 'required' => false, + ]) + ->add('translation', TranslationsType::class, [ + 'label' => false, + 'required' => true, + 'constraints' => [ + new NotNull() + ] + ]) + ; + + $builder->get('translation')->addModelTransformer(new TranslationTransformer($this->managerRegistry)); + } + + /** + * @inheritDoc + */ + public function configureOptions(OptionsResolver $resolver): void + { + parent::configureOptions($resolver); + $resolver->setDefault('data_class', AttributeGroupTranslation::class); + } + + /** + * @inheritDoc + */ + public function getBlockPrefix(): string + { + return 'attribute_group_translation'; + } +} diff --git a/lib/RoadizCoreBundle/src/Form/AttributeGroupType.php b/lib/RoadizCoreBundle/src/Form/AttributeGroupType.php new file mode 100644 index 00000000..3e2c1b58 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Form/AttributeGroupType.php @@ -0,0 +1,46 @@ +add('canonicalName', TextType::class, [ + 'label' => 'attribute_group.form.canonicalName', + 'empty_data' => '', + ]) + ->add('attributeGroupTranslations', CollectionType::class, [ + 'label' => 'attribute_group.form.attributeGroupTranslations', + 'allow_add' => true, + 'required' => false, + 'allow_delete' => true, + 'entry_type' => AttributeGroupTranslationType::class, + 'by_reference' => false, + 'entry_options' => [ + 'label' => false, + 'attr' => [ + 'class' => 'uk-form uk-form-horizontal' + ] + ], + 'attr' => [ + 'class' => 'rz-collection-form-type' + ] + ]) + ; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefault('data_class', AttributeGroup::class); + } +} diff --git a/lib/RoadizCoreBundle/src/Form/AttributeGroupsType.php b/lib/RoadizCoreBundle/src/Form/AttributeGroupsType.php new file mode 100644 index 00000000..39d0c4f6 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Form/AttributeGroupsType.php @@ -0,0 +1,68 @@ +entityManager = $entityManager; + } + + public function buildForm(FormBuilderInterface $builder, array $options): void + { + parent::buildForm($builder, $options); + + $builder->addModelTransformer(new AttributeGroupTransformer($this->entityManager)); + } + + /** + * {@inheritdoc} + */ + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setNormalizer('choices', function (Options $options, $choices) { + $criteria = []; + $ordering = [ + 'canonicalName' => 'ASC' + ]; + $attributeGroups = $this->entityManager + ->getRepository(AttributeGroup::class) + ->findBy($criteria, $ordering); + + /** @var AttributeGroup $attributeGroup */ + foreach ($attributeGroups as $attributeGroup) { + $choices[$attributeGroup->getName()] = $attributeGroup->getId(); + } + + return $choices; + }); + } + /** + * {@inheritdoc} + */ + public function getParent(): ?string + { + return ChoiceType::class; + } + /** + * {@inheritdoc} + */ + public function getBlockPrefix(): string + { + return 'attribute_groups'; + } +} diff --git a/lib/RoadizCoreBundle/src/Form/AttributeImportType.php b/lib/RoadizCoreBundle/src/Form/AttributeImportType.php new file mode 100644 index 00000000..7f006640 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Form/AttributeImportType.php @@ -0,0 +1,29 @@ +add('file', FileType::class, [ + 'label' => 'attributes.import_form.file.label', + 'help' => 'attributes.import_form.file.help', + 'constraints' => [ + new File([ + 'mimeTypes' => ['application/json', 'text/json', 'text/plain'] + ]) + ] + ]); + } +} diff --git a/lib/RoadizCoreBundle/src/Form/AttributeTranslationType.php b/lib/RoadizCoreBundle/src/Form/AttributeTranslationType.php new file mode 100644 index 00000000..2074be9a --- /dev/null +++ b/lib/RoadizCoreBundle/src/Form/AttributeTranslationType.php @@ -0,0 +1,87 @@ +managerRegistry = $managerRegistry; + } + + /** + * @inheritDoc + */ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder->add('label', TextType::class, [ + 'empty_data' => '', + 'label' => false, + 'required' => false, + ]) + ->add('translation', TranslationsType::class, [ + 'label' => false, + 'required' => true, + 'constraints' => [ + new NotNull() + ] + ]) + ->add('options', CollectionType::class, [ + 'label' => 'attributes.form.options', + 'required' => false, + 'allow_add' => true, + 'allow_delete' => true, + 'entry_options' => [ + 'required' => false, + ], + 'attr' => [ + 'class' => 'rz-collection-form-type' + ], + ]) + ; + + $builder->get('translation')->addModelTransformer(new TranslationTransformer($this->managerRegistry)); + } + + /** + * @inheritDoc + */ + public function configureOptions(OptionsResolver $resolver): void + { + parent::configureOptions($resolver); + $resolver->setDefault('data_class', AttributeTranslation::class); + $resolver->setDefault('constraints', [ + // Keep this constraint as class annotation is not validated + new UniqueEntity([ + 'fields' => ['attribute', 'translation'], + 'errorPath' => 'translation' + ]) + ]); + } + + /** + * @inheritDoc + */ + public function getBlockPrefix(): string + { + return 'attribute_translation'; + } +} diff --git a/lib/RoadizCoreBundle/src/Form/AttributeType.php b/lib/RoadizCoreBundle/src/Form/AttributeType.php new file mode 100644 index 00000000..f4400d74 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Form/AttributeType.php @@ -0,0 +1,104 @@ +add('code', TextType::class, [ + 'label' => 'attributes.form.code', + 'required' => true, + 'help' => 'attributes.form_help.code', + ]) + ->add('group', AttributeGroupsType::class, [ + 'label' => 'attributes.form.group', + 'required' => false, + 'help' => 'attributes.form_help.group', + 'placeholder' => 'attributes.form.group.placeholder' + ]) + ->add('color', ColorType::class, [ + 'label' => 'attributes.form.color', + 'help' => 'attributes.form_help.color', + 'required' => false, + ]) + ->add('type', ChoiceType::class, [ + 'label' => 'attributes.form.type', + 'required' => true, + 'choices' => [ + 'attributes.form.type.string' => AttributeInterface::STRING_T, + 'attributes.form.type.datetime' => AttributeInterface::DATETIME_T, + 'attributes.form.type.boolean' => AttributeInterface::BOOLEAN_T, + 'attributes.form.type.integer' => AttributeInterface::INTEGER_T, + 'attributes.form.type.decimal' => AttributeInterface::DECIMAL_T, + 'attributes.form.type.percent' => AttributeInterface::PERCENT_T, + 'attributes.form.type.email' => AttributeInterface::EMAIL_T, + 'attributes.form.type.colour' => AttributeInterface::COLOUR_T, + 'attributes.form.type.enum' => AttributeInterface::ENUM_T, + 'attributes.form.type.date' => AttributeInterface::DATE_T, + 'attributes.form.type.country' => AttributeInterface::COUNTRY_T, + ], + ]) + ->add('searchable', CheckboxType::class, [ + 'label' => 'attributes.form.searchable', + 'required' => false, + 'help' => 'attributes.form_help.searchable' + ]) + ->add('attributeTranslations', CollectionType::class, [ + 'label' => 'attributes.form.attributeTranslations', + 'allow_add' => true, + 'required' => false, + 'allow_delete' => true, + 'entry_type' => AttributeTranslationType::class, + 'by_reference' => false, + 'entry_options' => [ + 'label' => false, + 'attr' => [ + 'class' => 'uk-form uk-form-horizontal' + ] + ], + 'attr' => [ + 'class' => 'rz-collection-form-type' + ] + ]) + ->add('attributeDocuments', AttributeDocumentType::class, [ + 'label' => 'attributes.form.documents', + 'help' => 'attributes.form_help.documents', + 'required' => false, + 'attribute' => $builder->getForm()->getData() + ]) + ; + } + + /** + * @inheritDoc + */ + public function configureOptions(OptionsResolver $resolver): void + { + parent::configureOptions($resolver); + $resolver->setDefault('data_class', Attribute::class); + } + + /** + * @inheritDoc + */ + public function getBlockPrefix(): string + { + return 'attribute'; + } +} diff --git a/lib/RoadizCoreBundle/src/Form/AttributeValueTranslationType.php b/lib/RoadizCoreBundle/src/Form/AttributeValueTranslationType.php new file mode 100644 index 00000000..d6e5ad42 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Form/AttributeValueTranslationType.php @@ -0,0 +1,144 @@ +getData(); + + if ($attributeValueTranslation instanceof AttributeValueTranslationInterface) { + $defaultOptions = [ + 'required' => false, + 'empty_data' => null, + 'label' => false, + 'constraints' => [ + new Length([ + 'max' => 254 + ]) + ] + ]; + switch ($attributeValueTranslation->getAttributeValue()->getType()) { + case AttributeInterface::INTEGER_T: + $builder->add('value', IntegerType::class, $defaultOptions); + break; + case AttributeInterface::DECIMAL_T: + $builder->add('value', NumberType::class, $defaultOptions); + break; + case AttributeInterface::DATE_T: + $builder->add('value', DateType::class, array_merge($defaultOptions, [ + 'placeholder' => [ + 'year' => 'year', + 'month' => 'month', + 'day' => 'day' + ], + 'widget' => 'single_text', + 'format' => 'yyyy-MM-dd', + 'attr' => [ + 'class' => 'rz-datetime-field', + ], + 'constraints' => [] + ])); + break; + case AttributeInterface::COLOUR_T: + $builder->add('value', ColorType::class, $defaultOptions); + break; + case AttributeInterface::COUNTRY_T: + $builder->add('value', CountryType::class, $defaultOptions); + break; + case AttributeInterface::DATETIME_T: + $builder->add('value', DateTimeType::class, array_merge($defaultOptions, [ + 'placeholder' => [ + 'hour' => 'hour', + 'minute' => 'minute', + ], + 'date_widget' => 'single_text', + 'date_format' => 'yyyy-MM-dd', + 'attr' => [ + 'class' => 'rz-datetime-field', + ], + 'constraints' => [] + ])); + break; + case AttributeInterface::BOOLEAN_T: + $builder->add('value', CheckboxType::class, $defaultOptions); + break; + case AttributeInterface::ENUM_T: + $builder->add('value', ChoiceType::class, array_merge($defaultOptions, [ + 'required' => true, + 'choices' => $this->getOptions($attributeValueTranslation) + ])); + break; + case AttributeInterface::EMAIL_T: + $builder->add('value', EmailType::class, array_merge($defaultOptions, [ + 'constraints' => [ + new Email() + ] + ])); + break; + default: + $builder->add('value', TextType::class, $defaultOptions); + break; + } + } + } + + /** + * @param AttributeValueTranslationInterface $attributeValueTranslation + * + * @return AttributeInterface|null + */ + protected function getAttribute(AttributeValueTranslationInterface $attributeValueTranslation): ?AttributeInterface + { + return $attributeValueTranslation->getAttributeValue()->getAttribute(); + } + + /** + * @param AttributeValueTranslationInterface $attributeValueTranslation + * + * @return array + */ + protected function getOptions(AttributeValueTranslationInterface $attributeValueTranslation): array + { + $options = $this->getAttribute($attributeValueTranslation)->getOptions( + $attributeValueTranslation->getTranslation() + ); + if (null !== $options) { + $options = array_combine($options, $options); + } + + return array_merge([ + 'attributes.no_value' => null, + ], $options ?: []); + } + + /** + * @inheritDoc + */ + public function getBlockPrefix(): string + { + return 'attribute_value_translation'; + } +} diff --git a/lib/RoadizCoreBundle/src/Form/AttributeValueType.php b/lib/RoadizCoreBundle/src/Form/AttributeValueType.php new file mode 100644 index 00000000..93aa5d4c --- /dev/null +++ b/lib/RoadizCoreBundle/src/Form/AttributeValueType.php @@ -0,0 +1,47 @@ +add('attribute', AttributeChoiceType::class, [ + 'label' => 'attribute_values.form.attribute', + 'entityManager' => $options['entityManager'], + 'translation' => $options['translation'], + ]); + } + + /** + * @inheritDoc + */ + public function configureOptions(OptionsResolver $resolver): void + { + parent::configureOptions($resolver); + + $resolver->setRequired('entityManager'); + $resolver->setAllowedTypes('entityManager', [EntityManagerInterface::class]); + $resolver->setRequired('translation'); + $resolver->setAllowedTypes('translation', [Translation::class]); + } + + /** + * @inheritDoc + */ + public function getBlockPrefix(): string + { + return 'attribute_value'; + } +} diff --git a/lib/RoadizCoreBundle/src/Form/ColorType.php b/lib/RoadizCoreBundle/src/Form/ColorType.php new file mode 100644 index 00000000..695e9c54 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Form/ColorType.php @@ -0,0 +1,43 @@ +vars['attr']['class'] = 'colorpicker-input'; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefault('required', false); + $resolver->setDefault('constraints', [ + new HexadecimalColor(), + ]); + } + + public function getBlockPrefix(): string + { + return 'rz_color'; + } + + public function getParent(): ?string + { + return TextType::class; + } +} diff --git a/lib/RoadizCoreBundle/src/Form/CompareDateType.php b/lib/RoadizCoreBundle/src/Form/CompareDateType.php new file mode 100644 index 00000000..b8bb779f --- /dev/null +++ b/lib/RoadizCoreBundle/src/Form/CompareDateType.php @@ -0,0 +1,52 @@ +add('compareOp', ChoiceType::class, [ + 'label' => false, + 'choices' => [ + '<' => '<', + '>' => '>', + '<=' => '<=', + '>=' => '>=', + '=' => '=' + ] + ]) + ->add('compareDate', DateType::class, [ + 'label' => false, + 'required' => false, + 'widget' => 'single_text', + 'format' => 'yyyy-MM-dd', + 'attr' => [ + 'class' => 'rz-datetime-field', + ], + ]); + } + + /** + * {@inheritdoc} + */ + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'required' => false, + ]); + } + + public function getBlockPrefix(): string + { + return 'comparedate'; + } +} diff --git a/lib/RoadizCoreBundle/src/Form/CompareDatetimeType.php b/lib/RoadizCoreBundle/src/Form/CompareDatetimeType.php new file mode 100644 index 00000000..ea3f86d1 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Form/CompareDatetimeType.php @@ -0,0 +1,69 @@ +add('compareOp', ChoiceType::class, [ + 'label' => false, + 'choices' => [ + '<' => '<', + '>' => '>', + '<=' => '<=', + '>=' => '>=', + '=' => '=' + ] + ]) + ->add('compareDatetime', DateTimeType::class, [ + 'label' => false, + 'required' => false, + 'date_widget' => 'single_text', + 'date_format' => 'yyyy-MM-dd', + 'attr' => [ + 'class' => 'rz-datetime-field', + ], + 'placeholder' => [ + 'hour' => 'hour', + 'minute' => 'minute', + ], + ]); + } + + /** + * @inheritDoc + */ + public function buildView(FormView $view, FormInterface $form, array $options): void + { + parent::buildView($view, $form, $options); + + $view->vars['attr']['class'] = 'rz-compare-datetype'; + } + + /** + * {@inheritdoc} + */ + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'inherit_data' => false, + 'required' => false, + ]); + } + + public function getBlockPrefix(): string + { + return 'comparedatetime'; + } +} diff --git a/lib/RoadizCoreBundle/src/Form/Constraint/HexadecimalColor.php b/lib/RoadizCoreBundle/src/Form/Constraint/HexadecimalColor.php new file mode 100644 index 00000000..ecde5062 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Form/Constraint/HexadecimalColor.php @@ -0,0 +1,17 @@ +context->addViolation($constraint->message); + } + } + } +} diff --git a/lib/RoadizCoreBundle/src/Form/Constraint/NodeTypeField.php b/lib/RoadizCoreBundle/src/Form/Constraint/NodeTypeField.php new file mode 100644 index 00000000..34cc19c6 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Form/Constraint/NodeTypeField.php @@ -0,0 +1,25 @@ +isMarkdown()) { + $this->validateMarkdownOptions($value); + } + if ($value->isManyToMany() || $value->isManyToOne()) { + $this->validateJoinTypes($value, $constraint); + } + if ($value->isMultiProvider() || $value->isSingleProvider()) { + $this->validateProviderTypes($value, $constraint); + } + if ($value->isCollection()) { + $this->validateCollectionTypes($value, $constraint); + } + } else { + $this->context->buildViolation('Value is not a valid NodeTypeField.')->addViolation(); + } + } + + /** + * @param NodeTypeFieldEntity $value + * @param Constraint $constraint + */ + protected function validateJoinTypes(NodeTypeFieldEntity $value, Constraint $constraint): void + { + try { + $defaultValuesParsed = Yaml::parse($value->getDefaultValues() ?? ''); + if (null === $defaultValuesParsed) { + $this->context->buildViolation('default_values_should_not_be_empty_for_this_type')->atPath('defaultValues')->addViolation(); + } elseif (!is_array($defaultValuesParsed)) { + $this->context->buildViolation('default_values_should_be_a_yaml_configuration_for_this_type')->atPath('defaultValues')->addViolation(); + } else { + $configs = [ + $defaultValuesParsed, + ]; + $processor = new Processor(); + $joinConfig = new JoinNodeTypeFieldConfiguration(); + $configuration = $processor->processConfiguration($joinConfig, $configs); + + if (!class_exists($configuration['classname'])) { + $this->context->buildViolation('classname_%classname%_does_not_exist') + ->setParameter('%classname%', $configuration['classname']) + ->atPath('classname') + ->addViolation(); + return; + } + + $reflection = new \ReflectionClass($configuration['classname']); + if (!$reflection->implementsInterface(PersistableInterface::class)) { + $this->context->buildViolation('classname_%classname%_must_extend_abstract_entity_class') + ->setParameter('%classname%', $configuration['classname']) + ->atPath('classname') + ->addViolation(); + } + + if (!$reflection->hasMethod($configuration['displayable'])) { + $this->context->buildViolation('classname_%classname%_does_not_declare_%method%_method') + ->setParameter('%classname%', $configuration['classname']) + ->setParameter('%method%', $configuration['displayable']) + ->atPath('displayable') + ->addViolation(); + } + + if (!empty($configuration['alt_displayable'])) { + if (!$reflection->hasMethod($configuration['alt_displayable'])) { + $this->context->buildViolation('classname_%classname%_does_not_declare_%method%_method') + ->setParameter('%classname%', $configuration['classname']) + ->setParameter('%method%', $configuration['alt_displayable']) + ->atPath('alt_displayable') + ->addViolation(); + } + } + + if (!empty($configuration['thumbnail'])) { + if (!$reflection->hasMethod($configuration['thumbnail'])) { + $this->context->buildViolation('classname_%classname%_does_not_declare_%method%_method') + ->setParameter('%classname%', $configuration['classname']) + ->setParameter('%method%', $configuration['thumbnail']) + ->atPath('thumbnail') + ->addViolation(); + } + } + } + } catch (ParseException $e) { + $this->context->buildViolation($e->getMessage())->atPath('defaultValues')->addViolation(); + } catch (\RuntimeException $e) { + $this->context->buildViolation($e->getMessage())->atPath('defaultValues')->addViolation(); + } + } + + /** + * @param NodeTypeFieldEntity $value + * @param Constraint $constraint + * + * @throws \ReflectionException + */ + protected function validateProviderTypes(NodeTypeFieldEntity $value, Constraint $constraint): void + { + try { + if (null === $value->getDefaultValues()) { + $this->context->buildViolation('default_values_should_not_be_empty_for_this_type')->atPath('defaultValues')->addViolation(); + } else { + $defaultValuesParsed = Yaml::parse($value->getDefaultValues()); + if (null === $defaultValuesParsed) { + $this->context->buildViolation('default_values_should_not_be_empty_for_this_type')->atPath('defaultValues')->addViolation(); + } elseif (!is_array($defaultValuesParsed)) { + $this->context->buildViolation('default_values_should_be_a_yaml_configuration_for_this_type')->atPath('defaultValues')->addViolation(); + } else { + $configs = [ + $defaultValuesParsed, + ]; + $processor = new Processor(); + $providerConfig = new ProviderFieldConfiguration(); + $configuration = $processor->processConfiguration($providerConfig, $configs); + + if (!class_exists($configuration['classname'])) { + $this->context->buildViolation('classname_%classname%_does_not_exist') + ->setParameter('%classname%', $configuration['classname']) + ->atPath('defaultValues') + ->addViolation(); + return; + } + + $reflection = new \ReflectionClass($configuration['classname']); + if (!$reflection->isSubclassOf(AbstractExplorerProvider::class)) { + $this->context->buildViolation('classname_%classname%_must_extend_abstract_explorer_provider_class') + ->setParameter('%classname%', $configuration['classname']) + ->atPath('defaultValues') + ->addViolation(); + } + } + } + } catch (ParseException $e) { + $this->context->buildViolation($e->getMessage())->atPath('defaultValues')->addViolation(); + } catch (\RuntimeException $e) { + $this->context->buildViolation($e->getMessage())->atPath('defaultValues')->addViolation(); + } + } + + /** + * @param NodeTypeFieldEntity $value + * @param Constraint $constraint + */ + protected function validateCollectionTypes(NodeTypeFieldEntity $value, Constraint $constraint): void + { + try { + $defaultValuesParsed = Yaml::parse($value->getDefaultValues() ?? ''); + if (null === $defaultValuesParsed) { + $this->context->buildViolation('default_values_should_not_be_empty_for_this_type')->atPath('defaultValues')->addViolation(); + } elseif (!is_array($defaultValuesParsed)) { + $this->context->buildViolation('default_values_should_be_a_yaml_configuration_for_this_type')->atPath('defaultValues')->addViolation(); + } else { + $configs = [ + $defaultValuesParsed, + ]; + $processor = new Processor(); + $providerConfig = new CollectionFieldConfiguration(); + $configuration = $processor->processConfiguration($providerConfig, $configs); + + if (!class_exists($configuration['entry_type'])) { + $this->context->buildViolation('classname_%classname%_does_not_exist') + ->setParameter('%classname%', $configuration['entry_type']) + ->atPath('defaultValues') + ->addViolation(); + return; + } + + $reflection = new \ReflectionClass($configuration['entry_type']); + if (!$reflection->isSubclassOf(AbstractType::class)) { + $this->context->buildViolation('classname_%classname%_must_extend_abstract_type_class') + ->setParameter('%classname%', $configuration['entry_type']) + ->atPath('defaultValues') + ->addViolation(); + } + } + } catch (ParseException $e) { + $this->context->buildViolation($e->getMessage())->atPath('defaultValues')->addViolation(); + } catch (\RuntimeException $e) { + $this->context->buildViolation($e->getMessage())->atPath('defaultValues')->addViolation(); + } + } + + /** + * @param NodeTypeFieldEntity $value + */ + protected function validateMarkdownOptions(NodeTypeFieldEntity $value): void + { + try { + $options = Yaml::parse($value->getDefaultValues() ?? ''); + if (null !== $options && !is_array($options)) { + $this->context + ->buildViolation('Markdown options must be an array.') + ->atPath('defaultValues') + ->addViolation(); + } + } catch (ParseException $e) { + $this->context->buildViolation($e->getMessage())->atPath('defaultValues')->addViolation(); + } + } +} diff --git a/lib/RoadizCoreBundle/src/Form/Constraint/NonSqlReservedWord.php b/lib/RoadizCoreBundle/src/Form/Constraint/NonSqlReservedWord.php new file mode 100644 index 00000000..07fd1af0 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Form/Constraint/NonSqlReservedWord.php @@ -0,0 +1,72 @@ + + */ + public static array $forbiddenNames = [ + 'title', 'id', 'translation', 'node', 'urlAliases', 'url_aliases', 'documentsByFields', + 'publishedAt', 'published_at', 'published at', 'documents_by_fields', + 'metaTitle', 'metaKeywords', 'metaDescription', 'order', 'integer', 'int', 'float', 'join', + 'inner', 'select', 'from', 'where', 'by', 'varchar', + 'text', 'enum', 'left', 'outer', 'blob', 'accessible', + 'add', 'all', 'alter', 'analyze', 'and', 'as', 'asc', + 'asensitive', 'before', 'between', 'bigint', 'binary', + 'blob', 'both', 'by', 'call', 'cascade', 'case', 'change', + 'char', 'character', 'check', 'collate', 'column', 'condition', + 'constraint', 'continue', 'convert', 'create', 'cross', + 'current_date', 'current_time', 'current_timestamp', + 'current_user', 'cursor', 'database', 'databases', + 'day_hour', 'day_microsecond', 'day_minute', 'day_second', + 'dec', 'decimal', 'declare', 'default', 'delayed', 'delete', + 'desc', 'describe', 'deterministic', 'distinct', 'distinctrow', + 'div', 'double', 'drop', 'dual', 'each', 'else', 'elseif', + 'enclosed', 'escaped', 'exists', 'exit', 'explain', 'false', + 'fetch', 'float', 'float4', 'float8', 'for', 'force', 'foreign', + 'from', 'fulltext', 'get', 'grant', 'group', 'having', + 'high_priority', 'hour_microsecond', 'hour_minute', + 'hour_second', 'if', 'ignore', 'in', 'index', 'infile', 'inner', + 'inout', 'insensitive', 'insert', 'int', 'int1', 'int2', 'int3', + 'int4', 'int8', 'integer', 'interval', 'into', 'io_after_gtids', + 'io_before_gtids', 'is', 'iterate', 'join', 'key', 'keys', 'kill', + 'leading', 'leave', 'left', 'like', 'limit', 'linear', 'lines', + 'load', 'localtime', 'localtimestamp', 'lock', 'long', 'longblob', + 'longtext', 'loop', 'low_priority', 'master_bind', 'master_ssl_verify_server_cert', + 'match', 'maxvalue', 'mediumblob', 'mediumint', 'mediumtext', + 'middleint', 'minute_microsecond', 'minute_second', 'mod', 'modifies', + 'natural', 'not', 'no_write_to_binlog', 'null', 'numeric', 'on', + 'optimize', 'option', 'optionally', 'or', 'order', 'out', 'outer', + 'outfile', 'partition', 'precision', 'primary', 'procedure', 'purge', + 'range', 'read', 'reads', 'read_write', 'real', 'references', 'regexp', + 'release', 'rename', 'repeat', 'replace', 'require', 'resignal', + 'restrict', 'return', 'revoke', 'right', 'rlike', 'schema', 'schemas', + 'second_microsecond', 'select', 'sensitive', 'separator', 'set', + 'show', 'signal', 'smallint', 'spatial', 'specific', 'sql', + 'sqlexception', 'sqlstate', 'sqlwarning', 'sql_big_result', + 'sql_calc_found_rows', 'sql_small_result', 'ssl', 'starting', + 'straight_join', 'table', 'terminated', 'then', 'tinyblob', + 'tinyint', 'tinytext', 'to', 'trailing', 'trigger', 'true', + 'undo', 'union', 'unique', 'unlock', 'unsigned', 'update', 'usage', + 'use', 'using', 'utc_date', 'utc_time', 'utc_timestamp', 'values', + 'varbinary', 'varchar', 'varcharacter', 'varying', 'when', 'where', + 'while', 'with', 'write', 'xor', 'year_month', 'zerofill', + ]; + + public string $message = 'string.should.not.be.a.sql.reserved.word'; +} diff --git a/lib/RoadizCoreBundle/src/Form/Constraint/NonSqlReservedWordValidator.php b/lib/RoadizCoreBundle/src/Form/Constraint/NonSqlReservedWordValidator.php new file mode 100644 index 00000000..f65c7461 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Form/Constraint/NonSqlReservedWordValidator.php @@ -0,0 +1,29 @@ +context->addViolation($constraint->message); + } + } + } + } +} diff --git a/lib/RoadizCoreBundle/src/Form/Constraint/Recaptcha.php b/lib/RoadizCoreBundle/src/Form/Constraint/Recaptcha.php new file mode 100644 index 00000000..6eec2ed5 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Form/Constraint/Recaptcha.php @@ -0,0 +1,30 @@ +requestStack = $requestStack; + $this->recaptchaPrivateKey = $recaptchaPrivateKey; + } + + /** + * @param mixed $data + * @param Constraint $constraint + * @throws \GuzzleHttp\Exception\GuzzleException + * @see \Symfony\Component\Validator\ConstraintValidator::validate() + */ + public function validate(mixed $data, Constraint $constraint): void + { + if ($constraint instanceof Recaptcha) { + $propertyPath = $this->context->getPropertyPath(); + + if (null === $this->requestStack->getCurrentRequest()) { + $this->context->buildViolation('Request is not defined') + ->atPath($propertyPath) + ->addViolation(); + } + + $responseField = $this->requestStack->getCurrentRequest()->get($constraint->fieldName); + + if (empty($responseField)) { + $this->context->buildViolation($constraint->emptyMessage) + ->atPath($propertyPath) + ->addViolation(); + } elseif (true !== $response = $this->check($responseField, $constraint->verifyUrl)) { + $this->context->buildViolation($constraint->invalidMessage) + ->atPath($propertyPath) + ->addViolation(); + + if (is_array($response)) { + foreach ($response as $errorCode) { + $this->context->buildViolation($errorCode) + ->atPath($propertyPath) + ->addViolation(); + } + } elseif (is_string($response)) { + $this->context->buildViolation($response) + ->atPath($propertyPath) + ->addViolation(); + } + } + } + } + + /** + * Makes a request to recaptcha service and checks if recaptcha field is valid. + * Returns Google error-codes if recaptcha fails. + * + * @param string $responseValue + * @param string $verifyUrl + * @return true|mixed + * @throws \GuzzleHttp\Exception\GuzzleException + */ + public function check( + string $responseValue, + string $verifyUrl = 'https://www.google.com/recaptcha/api/siteverify' + ): mixed { + if (empty($this->recaptchaPrivateKey)) { + return true; + } + + $data = [ + 'secret' => $this->recaptchaPrivateKey, + 'response' => $responseValue, + ]; + + $client = new Client(); + $response = $client->post($verifyUrl, [ + 'form_params' => $data, + 'connect_timeout' => 10, + 'timeout' => 10, + 'headers' => [ + 'Accept' => 'application/json', + ] + ]); + $jsonResponse = json_decode($response->getBody()->getContents(), true); + + return (isset($jsonResponse['success']) && $jsonResponse['success'] === true) ? + (true) : + ($jsonResponse['error-codes']); + } +} diff --git a/lib/RoadizCoreBundle/src/Form/Constraint/SimpleLatinString.php b/lib/RoadizCoreBundle/src/Form/Constraint/SimpleLatinString.php new file mode 100644 index 00000000..474ef47f --- /dev/null +++ b/lib/RoadizCoreBundle/src/Form/Constraint/SimpleLatinString.php @@ -0,0 +1,17 @@ +context->addViolation($constraint->message); + } + } + } +} diff --git a/lib/RoadizCoreBundle/src/Form/Constraint/UniqueEntity.php b/lib/RoadizCoreBundle/src/Form/Constraint/UniqueEntity.php new file mode 100644 index 00000000..f5bfd400 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Form/Constraint/UniqueEntity.php @@ -0,0 +1,42 @@ + + * @see https://github.com/symfony/doctrine-bridge/blob/master/Validator/Constraints/UniqueEntity.php + * @deprecated Use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity + */ +class UniqueEntity extends Constraint +{ + public const NOT_UNIQUE_ERROR = '23bd9dbf-6b9b-41cd-a99e-4844bcf3077f'; + + public string $message = 'value.is.already.used'; + /** + * @var class-string|null + */ + public ?string $entityClass = null; + public string $repositoryMethod = 'findBy'; + public ?string $errorPath = null; + public array $fields = []; + public bool $ignoreNull = true; + + public function getRequiredOptions(): array + { + return ['fields']; + } + + public function getDefaultOption(): string + { + return 'fields'; + } +} diff --git a/lib/RoadizCoreBundle/src/Form/Constraint/UniqueEntityValidator.php b/lib/RoadizCoreBundle/src/Form/Constraint/UniqueEntityValidator.php new file mode 100644 index 00000000..701af696 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Form/Constraint/UniqueEntityValidator.php @@ -0,0 +1,171 @@ + + * @package RZ\Roadiz\CoreBundle\Form\Constraint + * @see https://github.com/symfony/doctrine-bridge/blob/master/Validator/Constraints/UniqueEntityValidator.php + * @deprecated Use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntityValidator + */ +class UniqueEntityValidator extends ConstraintValidator +{ + protected ManagerRegistry $managerRegistry; + + /** + * @param ManagerRegistry $managerRegistry + */ + public function __construct(ManagerRegistry $managerRegistry) + { + $this->managerRegistry = $managerRegistry; + } + + /** + * @param mixed $value + * @param UniqueEntity $constraint + * + * @throws \Exception + */ + public function validate(mixed $value, Constraint $constraint): void + { + if (!$constraint instanceof UniqueEntity) { + throw new UnexpectedTypeException($constraint, __NAMESPACE__ . '\UniqueEntity'); + } + + $fields = $constraint->fields; + if (0 === count($fields)) { + throw new ConstraintDefinitionException('At least one field has to be specified.'); + } + + $class = $this->managerRegistry + ->getManagerForClass(get_class($value)) + ->getClassMetadata(get_class($value)); + + $criteria = []; + $hasNullValue = false; + foreach ($fields as $fieldName) { + if (!$class instanceof ClassMetadataInfo) { + throw new ConstraintDefinitionException(sprintf('The class "%s" is not mapped by Doctrine, so it cannot be validated for uniqueness.', get_class($value))); + } + if (!$class->hasField($fieldName) && !$class->hasAssociation($fieldName)) { + throw new ConstraintDefinitionException(sprintf('The field "%s" is not mapped by Doctrine, so it cannot be validated for uniqueness.', $fieldName)); + } + $fieldValue = $class->getReflectionProperty($fieldName)->getValue($value); + + if (null === $fieldValue) { + $hasNullValue = true; + } + if ($constraint->ignoreNull && null === $fieldValue) { + continue; + } + $criteria[$fieldName] = $fieldValue; + if (null !== $criteria[$fieldName] && $class->hasAssociation($fieldName)) { + /* Ensure the Proxy is initialized before using reflection to + * read its identifiers. This is necessary because the wrapped + * getter methods in the Proxy are being bypassed. + */ + $this->managerRegistry + ->getManagerForClass(get_class($value)) + ->initializeObject($criteria[$fieldName]); + } + } + // validation doesn't fail if one of the fields is null and if null values should be ignored + if ($hasNullValue && $constraint->ignoreNull) { + return; + } + // skip validation if there are no criteria (this can happen when the + // "ignoreNull" option is enabled and fields to be checked are null + if (empty($criteria)) { + return; + } + if (null !== $constraint->entityClass) { + /* Retrieve repository from given entity name. + * We ensure the retrieved repository can handle the entity + * by checking the entity is the same, or subclass of the supported entity. + */ + $repository = $this->managerRegistry->getRepository($constraint->entityClass); + $supportedClass = $repository->getClassName(); + if (!$value instanceof $supportedClass) { + throw new ConstraintDefinitionException(sprintf('The "%s" entity repository does not support the "%s" entity. The entity should be an instance of or extend "%s".', $constraint->entityClass, $class->getName(), $supportedClass)); + } + } else { + $repository = $this->managerRegistry->getRepository(get_class($value)); + } + $result = $repository->{$constraint->repositoryMethod}($criteria); + if ($result instanceof \IteratorAggregate) { + $result = $result->getIterator(); + } + /* If the result is a MongoCursor, it must be advanced to the first + * element. Rewinding should have no ill effect if $result is another + * iterator implementation. + */ + if ($result instanceof \Iterator) { + $result->rewind(); + } elseif (is_array($result)) { + reset($result); + } + /* If no entity matched the query criteria or a single entity matched, + * which is the same as the entity being validated, the criteria is + * unique. + */ + if (0 === count($result) || (1 === count($result) && $value === ($result instanceof \Iterator ? $result->current() : current($result)))) { + return; + } + + $errorPath = null !== $constraint->errorPath ? $constraint->errorPath : $fields[0]; + $invalidValue = $criteria[$errorPath] ?? $criteria[$fields[0]]; + + $this->context->buildViolation($constraint->message) + ->atPath($errorPath) + ->setParameter('{{ value }}', $this->formatWithIdentifiers($class, $invalidValue)) + ->setInvalidValue($invalidValue) + ->setCode(UniqueEntity::NOT_UNIQUE_ERROR) + ->addViolation(); + } + + private function formatWithIdentifiers(ClassMetadata $class, mixed $value): string + { + if (!is_object($value) || $value instanceof \DateTimeInterface) { + return $this->formatValue($value, self::PRETTY_DATE); + } + if ($class->getName() !== $idClass = get_class($value)) { + // non unique value might be a composite PK that consists of other entity objects + if ($this->managerRegistry->getManagerForClass($idClass)->getMetadataFactory()->hasMetadataFor($idClass)) { + $identifiers = $this->managerRegistry + ->getManagerForClass($idClass) + ->getClassMetadata($idClass) + ->getIdentifierValues($value); + } else { + // this case might happen if the non unique column has a custom doctrine type and its value is an object + // in which case we cannot get any identifiers for it + $identifiers = []; + } + } else { + $identifiers = $class->getIdentifierValues($value); + } + if (!$identifiers) { + return sprintf('object("%s")', $idClass); + } + array_walk($identifiers, function (&$id, $field) { + if (!is_object($id) || $id instanceof \DateTimeInterface) { + $idAsString = $this->formatValue($id, self::PRETTY_DATE); + } else { + $idAsString = sprintf('object("%s")', get_class($id)); + } + $id = sprintf('%s => %s', $field, $idAsString); + }); + return sprintf('object("%s") identified by (%s)', $idClass, implode(', ', $identifiers)); + } +} diff --git a/lib/RoadizCoreBundle/src/Form/Constraint/UniqueFilename.php b/lib/RoadizCoreBundle/src/Form/Constraint/UniqueFilename.php new file mode 100644 index 00000000..3293d75c --- /dev/null +++ b/lib/RoadizCoreBundle/src/Form/Constraint/UniqueFilename.php @@ -0,0 +1,18 @@ +documentsStorage = $documentsStorage; + } + + /** + * @param mixed $value + * @param Constraint $constraint + * @throws FilesystemException + */ + public function validate(mixed $value, Constraint $constraint): void + { + if ($constraint instanceof UniqueFilename) { + $document = $constraint->document; + /* + * If value is already the filename + * do nothing. + */ + if ( + null !== $document && + $value == $document->getFilename() + ) { + return; + } + + $folder = $document->getMountFolderPath(); + + if ($this->documentsStorage->fileExists($folder . '/' . $value)) { + $this->context->addViolation($constraint->message); + } + } + } +} diff --git a/lib/RoadizCoreBundle/src/Form/Constraint/UniqueNodeName.php b/lib/RoadizCoreBundle/src/Form/Constraint/UniqueNodeName.php new file mode 100644 index 00000000..de521e4c --- /dev/null +++ b/lib/RoadizCoreBundle/src/Form/Constraint/UniqueNodeName.php @@ -0,0 +1,19 @@ +managerRegistry = $managerRegistry; + } + + /** + * @param mixed $value + * @param UniqueNodeName $constraint + * @throws NoResultException + * @throws NonUniqueResultException + */ + public function validate(mixed $value, Constraint $constraint): void + { + $value = StringHandler::slugify($value); + + /* + * If value is already the node name + * do nothing. + */ + if (null !== $constraint->currentValue && $value == $constraint->currentValue) { + return; + } + + if (true === $this->urlAliasExists($value)) { + $this->context->addViolation($constraint->messageUrlAlias); + } elseif (true === $this->nodeNameExists($value)) { + $this->context->addViolation($constraint->message); + } + } + + /** + * @param string $name + * + * @return bool + */ + protected function urlAliasExists(string $name): bool + { + return (bool) $this->managerRegistry->getRepository(UrlAlias::class)->exists($name); + } + + /** + * @param string $name + * + * @return bool + * @throws \Doctrine\ORM\NonUniqueResultException|\Doctrine\ORM\NoResultException + */ + protected function nodeNameExists(string $name): bool + { + /** @var NodeRepository $nodeRepo */ + $nodeRepo = $this->managerRegistry->getRepository(Node::class); + $nodeRepo->setDisplayingNotPublishedNodes(true); + return $nodeRepo->exists($name); + } +} diff --git a/lib/RoadizCoreBundle/src/Form/Constraint/UniqueTagName.php b/lib/RoadizCoreBundle/src/Form/Constraint/UniqueTagName.php new file mode 100644 index 00000000..c76fd1fc --- /dev/null +++ b/lib/RoadizCoreBundle/src/Form/Constraint/UniqueTagName.php @@ -0,0 +1,13 @@ +managerRegistry = $managerRegistry; + } + + /** + * @param string $value + * @param UniqueTagName $constraint + */ + public function validate(mixed $value, Constraint $constraint): void + { + if ($this->isMulti($value)) { + $names = explode(',', $value); + foreach ($names as $name) { + $name = strip_tags(trim($name)); + $this->testSingleValue($name, $constraint); + } + } else { + $this->testSingleValue($value, $constraint); + } + } + + /** + * @param string|null $value + * @param UniqueTagName $constraint + */ + protected function testSingleValue(?string $value, Constraint $constraint): void + { + $value = StringHandler::slugify($value ?? ''); + + /* + * If value is already the node name + * do nothing. + */ + if (null !== $constraint->currentValue && $value == $constraint->currentValue) { + return; + } + + if (true === $this->tagNameExists($value)) { + $this->context->addViolation($constraint->message, [ + '%name%' => $value, + ]); + } + } + + /** + * @param string $name + * + * @return bool + */ + protected function tagNameExists(string $name): bool + { + $entity = $this->managerRegistry->getRepository(Tag::class)->findOneByTagName($name); + + return (null !== $entity); + } + + /** + * @param string|null $value + * @return bool + */ + protected function isMulti(?string $value): bool + { + return (bool) strpos($value ?? '', ','); + } +} diff --git a/lib/RoadizCoreBundle/src/Form/Constraint/ValidAccountConfirmationToken.php b/lib/RoadizCoreBundle/src/Form/Constraint/ValidAccountConfirmationToken.php new file mode 100644 index 00000000..74a7c642 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Form/Constraint/ValidAccountConfirmationToken.php @@ -0,0 +1,21 @@ +managerRegistry = $managerRegistry; + } + + /** + * @param mixed $value + * @param ValidAccountConfirmationToken $constraint + * @return void + */ + public function validate(mixed $value, Constraint $constraint): void + { + $user = $this->managerRegistry + ->getRepository(User::class) + ->findOneByConfirmationToken($value); + + if (null === $user) { + $this->context->addViolation($constraint->message); + } elseif (!$user->isPasswordRequestNonExpired($constraint->ttl)) { + $this->context->addViolation($constraint->expiredMessage); + } + } +} diff --git a/lib/RoadizCoreBundle/src/Form/Constraint/ValidAccountEmail.php b/lib/RoadizCoreBundle/src/Form/Constraint/ValidAccountEmail.php new file mode 100644 index 00000000..8c5f0e78 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Form/Constraint/ValidAccountEmail.php @@ -0,0 +1,12 @@ +managerRegistry = $managerRegistry; + } + + /** + * @param mixed $value + * @param ValidAccountEmail $constraint + * @return void + */ + public function validate(mixed $value, Constraint $constraint): void + { + $user = $this->managerRegistry + ->getRepository(User::class) + ->findOneByEmail($value); + + if (null === $user) { + $this->context->buildViolation($constraint->message) + ->setParameter('%email%', $this->formatValue($value)) + ->setInvalidValue($value) + ->addViolation(); + } + } +} diff --git a/lib/RoadizCoreBundle/src/Form/Constraint/ValidFacebookName.php b/lib/RoadizCoreBundle/src/Form/Constraint/ValidFacebookName.php new file mode 100644 index 00000000..ca9c6dd2 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Form/Constraint/ValidFacebookName.php @@ -0,0 +1,17 @@ +context->addViolation($constraint->message); + } else { + /* + * Test if the username really exists. + */ + $facebook = new FacebookPictureFinder($value); + try { + $facebook->getPictureUrl(); + } catch (\Exception $e) { + $this->context->addViolation($constraint->message); + } + } + } + } +} diff --git a/lib/RoadizCoreBundle/src/Form/Constraint/ValidJson.php b/lib/RoadizCoreBundle/src/Form/Constraint/ValidJson.php new file mode 100644 index 00000000..a797fad2 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Form/Constraint/ValidJson.php @@ -0,0 +1,12 @@ +context->addViolation($constraint->message, [ + '{{ error }}' => $e->getMessage() + ]); + } + } + } +} diff --git a/lib/RoadizCoreBundle/src/Form/Constraint/ValidYaml.php b/lib/RoadizCoreBundle/src/Form/Constraint/ValidYaml.php new file mode 100644 index 00000000..420d2652 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Form/Constraint/ValidYaml.php @@ -0,0 +1,12 @@ +context->addViolation($constraint->message, [ + '{{ error }}' => $e->getMessage() + ]); + } + } + } +} diff --git a/lib/RoadizCoreBundle/src/Form/CreatePasswordType.php b/lib/RoadizCoreBundle/src/Form/CreatePasswordType.php new file mode 100644 index 00000000..edb2630e --- /dev/null +++ b/lib/RoadizCoreBundle/src/Form/CreatePasswordType.php @@ -0,0 +1,50 @@ +setDefaults([ + 'type' => PasswordType::class, + 'invalid_message' => 'password.must.match', + 'options' => [ + 'constraints' => [ + new NotInPasswordCommonList() + ] + ], + 'first_options' => [ + 'label' => 'choose.a.new.password', + ], + 'second_options' => [ + 'label' => 'passwordVerify', + ], + 'required' => false, + 'error_mapping' => function (Options $options) { + return ['.' => $options['first_name']]; + }, + ]); + } + + /** + * {@inheritdoc} + */ + public function getBlockPrefix(): string + { + return 'repeated'; + } +} diff --git a/lib/RoadizCoreBundle/src/Form/CssType.php b/lib/RoadizCoreBundle/src/Form/CssType.php new file mode 100644 index 00000000..f1b124ea --- /dev/null +++ b/lib/RoadizCoreBundle/src/Form/CssType.php @@ -0,0 +1,49 @@ +vars['attr']['class'] = 'css_textarea'; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'required' => false, + ]); + } +} diff --git a/lib/RoadizCoreBundle/src/Form/CustomFormsType.php b/lib/RoadizCoreBundle/src/Form/CustomFormsType.php new file mode 100644 index 00000000..858ec074 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Form/CustomFormsType.php @@ -0,0 +1,327 @@ +recaptchaPrivateKey = $recaptchaPrivateKey; + $this->recaptchaPublicKey = $recaptchaPublicKey; + } + + /** + * @param FormBuilderInterface $builder + * @param array $options + */ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $fieldsArray = $this->getFieldsByGroups($options); + + /** @var CustomFormField|array $field */ + foreach ($fieldsArray as $group => $field) { + if ($field instanceof CustomFormField) { + $this->addSingleField($builder, $field, $options); + } elseif (is_array($field)) { + $groupCanonical = StringHandler::slugify($group); + $subBuilder = $builder->create($groupCanonical, FormType::class, [ + 'label' => $group, + 'inherit_data' => true, + 'attr' => [ + 'data-group-wrapper' => $groupCanonical, + ] + ]); + /** @var CustomFormField $subfield */ + foreach ($field as $subfield) { + $this->addSingleField($subBuilder, $subfield, $options); + } + $builder->add($subBuilder); + } + } + + /* + * Add Google Recaptcha if setting optional options. + */ + if ( + !empty($this->recaptchaPublicKey) && + !empty($this->recaptchaPrivateKey) + ) { + $builder->add($options['recaptcha_name'], RecaptchaType::class, [ + 'label' => false, + 'configs' => [ + 'publicKey' => $this->recaptchaPublicKey, + ], + 'constraints' => [ + new Recaptcha([ + 'privateKey' => $this->recaptchaPrivateKey, + 'fieldName' => $options['recaptcha_name'] + ]), + ], + ]); + } + } + + /** + * @param array $options + * @return array + */ + protected function getFieldsByGroups(array $options): array + { + $fieldsArray = []; + $fields = $options['customForm']->getFields(); + + /** @var CustomFormField $field */ + foreach ($fields as $field) { + if ($field->getGroupName() != '') { + if (!isset($fieldsArray[$field->getGroupName()])) { + $fieldsArray[$field->getGroupName()] = []; + } + $fieldsArray[$field->getGroupName()][] = $field; + } else { + $fieldsArray[] = $field; + } + } + + return $fieldsArray; + } + + /** + * @param FormBuilderInterface $builder + * @param CustomFormField $field + * @param array $formOptions + * @return $this + */ + protected function addSingleField(FormBuilderInterface $builder, CustomFormField $field, array $formOptions): self + { + $builder->add( + $field->getName(), + $this->getTypeForField($field), + $this->getOptionsForField($field, $formOptions) + ); + return $this; + } + + /** + * @param CustomFormField $field + * @return class-string + */ + protected function getTypeForField(CustomFormField $field): string + { + switch ($field->getType()) { + case AbstractField::ENUM_T: + case AbstractField::MULTIPLE_T: + case AbstractField::RADIO_GROUP_T: + case AbstractField::CHECK_GROUP_T: + return ChoiceType::class; + case AbstractField::DOCUMENTS_T: + return FileType::class; + case AbstractField::MARKDOWN_T: + return MarkdownType::class; + case AbstractField::COLOUR_T: + return ColorType::class; + case AbstractField::DATETIME_T: + return DateTimeType::class; + case AbstractField::DATE_T: + return DateType::class; + case AbstractField::RICHTEXT_T: + case AbstractField::TEXT_T: + return TextareaType::class; + case AbstractField::BOOLEAN_T: + return CheckboxType::class; + case AbstractField::INTEGER_T: + return IntegerType::class; + case AbstractField::DECIMAL_T: + return NumberType::class; + case AbstractField::EMAIL_T: + return EmailType::class; + case AbstractField::COUNTRY_T: + return CountryType::class; + default: + return TextType::class; + } + } + + /** + * @param CustomFormField $field + * @param array $formOptions + * @return array + */ + protected function getOptionsForField(CustomFormField $field, array $formOptions): array + { + $option = [ + "label" => $field->getLabel(), + 'help' => $field->getDescription(), + 'attr' => [ + 'data-group' => $field->getGroupName(), + ], + ]; + + if ($field->getPlaceholder() !== '') { + $option['attr']['placeholder'] = $field->getPlaceholder(); + } + + if ($field->isRequired()) { + $option['required'] = true; + $option['constraints'] = [ + new NotBlank([ + 'message' => 'you.need.to.fill.this.required.field' + ]) + ]; + } else { + $option['required'] = false; + } + + switch ($field->getType()) { + case AbstractField::DATETIME_T: + $option["widget"] = 'single_text'; + $option["format"] = DateTimeType::HTML5_FORMAT; + break; + case AbstractField::DATE_T: + $option["widget"] = 'single_text'; + $option["format"] = DateType::HTML5_FORMAT; + break; + case AbstractField::ENUM_T: + if ($field->getPlaceholder() !== '') { + $option['placeholder'] = $field->getPlaceholder(); + } + $option["choices"] = $this->getChoices($field); + $option["expanded"] = $field->isExpanded(); + + if ($formOptions['forceExpanded']) { + $option["expanded"] = true; + } + if ($field->isRequired() === false) { + $option['placeholder'] = 'none'; + } + break; + case AbstractField::MULTIPLE_T: + if ($field->getPlaceholder() !== '') { + $option['placeholder'] = $field->getPlaceholder(); + } + $option["choices"] = $this->getChoices($field); + $option["multiple"] = true; + $option["expanded"] = $field->isExpanded(); + + if ($formOptions['forceExpanded']) { + $option["expanded"] = true; + } + if ($field->isRequired() === false) { + $option['placeholder'] = 'none'; + } + break; + case AbstractField::DOCUMENTS_T: + $option['multiple'] = true; + $option['mapped'] = false; + $mimeTypes = [ + 'application/pdf', + 'application/x-pdf', + 'image/jpeg', + 'image/png', + 'image/gif', + ]; + if (!empty($field->getDefaultValues())) { + $mimeTypes = explode(',', $field->getDefaultValues()); + $mimeTypes = array_map('trim', $mimeTypes); + } + $option['constraints'][] = new All([ + 'constraints' => [ + new File([ + 'maxSize' => '10m', + 'mimeTypes' => $mimeTypes + ]) + ] + ]); + break; + case AbstractField::COUNTRY_T: + $option["expanded"] = $field->isExpanded(); + if ($field->getPlaceholder() !== '') { + $option['placeholder'] = $field->getPlaceholder(); + } + if (!empty($field->getDefaultValues())) { + $countries = explode(',', $field->getDefaultValues()); + $countries = array_map('trim', $countries); + $option['preferred_choices'] = $countries; + } + break; + case AbstractField::EMAIL_T: + if (!isset($option['constraints'])) { + $option['constraints'] = []; + } + $option['constraints'][] = new Email(); + break; + default: + break; + } + return $option; + } + + /** + * @param CustomFormField $field + * @return array + */ + protected function getChoices(CustomFormField $field): array + { + $choices = explode(',', $field->getDefaultValues() ?? ''); + $choices = array_map('trim', $choices); + return array_combine(array_values($choices), array_values($choices)); + } + + /** + * @param OptionsResolver $resolver + */ + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'recaptcha_name' => Recaptcha::FORM_NAME, + 'forceExpanded' => false, + 'csrf_protection' => false, + ]); + + $resolver->setRequired('customForm'); + + $resolver->setAllowedTypes('customForm', [CustomForm::class]); + $resolver->setAllowedTypes('forceExpanded', ['boolean']); + $resolver->setAllowedTypes('recaptcha_name', ['string']); + } + + /** + * @return string + */ + public function getBlockPrefix(): string + { + return 'custom_form_public'; + } +} diff --git a/lib/RoadizCoreBundle/src/Form/DataTransformer/AttributeDocumentsTransformer.php b/lib/RoadizCoreBundle/src/Form/DataTransformer/AttributeDocumentsTransformer.php new file mode 100644 index 00000000..61343456 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Form/DataTransformer/AttributeDocumentsTransformer.php @@ -0,0 +1,84 @@ +manager = $manager; + $this->attribute = $attribute; + } + + /** + * Transform AttributeDocuments join entities + * to Document entities for displaying in document VueJS component. + * + * @param AttributeDocuments[]|null $value + * @return Document[] + */ + public function transform(mixed $value): array + { + if (empty($value)) { + return []; + } + $documents = []; + foreach ($value as $attributeDocument) { + $documents[] = $attributeDocument->getDocument(); + } + + return $documents; + } + + /** + * @param array $value + * @return ArrayCollection + */ + public function reverseTransform(mixed $value): ArrayCollection + { + if (!$value) { + return new ArrayCollection(); + } + + $documents = new ArrayCollection(); + $position = 0; + foreach ($value as $documentId) { + $document = $this->manager + ->getRepository(Document::class) + ->find($documentId) + ; + if (null === $document) { + throw new TransformationFailedException(sprintf( + 'A document with id "%s" does not exist!', + $documentId + )); + } + + $ttd = new AttributeDocuments($this->attribute, $document); + $ttd->setPosition($position); + $this->manager->persist($ttd); + $documents->add($ttd); + + $position++; + } + + return $documents; + } +} diff --git a/lib/RoadizCoreBundle/src/Form/DataTransformer/AttributeGroupTransformer.php b/lib/RoadizCoreBundle/src/Form/DataTransformer/AttributeGroupTransformer.php new file mode 100644 index 00000000..8fb7c550 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Form/DataTransformer/AttributeGroupTransformer.php @@ -0,0 +1,67 @@ +manager = $manager; + } + + /** + * @param AttributeGroup|null $value + * @return int|string + */ + public function transform(mixed $value): int|string + { + if (!$value instanceof AttributeGroup) { + return ''; + } + return $value->getId(); + } + + /** + * @param mixed $value + * @return null|AttributeGroup + */ + public function reverseTransform(mixed $value): ?AttributeGroup + { + if (!$value) { + return null; + } + + $attributeGroup = $this->manager + ->getRepository(AttributeGroup::class) + ->find($value) + ; + + if (null === $attributeGroup) { + // causes a validation error + // this message is not shown to the user + // see the invalid_message option + throw new TransformationFailedException(sprintf( + 'A attribute-group with id "%s" does not exist!', + $value + )); + } + + return $attributeGroup; + } +} diff --git a/lib/RoadizCoreBundle/src/Form/DataTransformer/DocumentCollectionTransformer.php b/lib/RoadizCoreBundle/src/Form/DataTransformer/DocumentCollectionTransformer.php new file mode 100644 index 00000000..28fe6359 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Form/DataTransformer/DocumentCollectionTransformer.php @@ -0,0 +1,23 @@ + + */ + private string $classname; + + /** + * @param ObjectManager $manager + * @param class-string $classname + * @param bool $asCollection + */ + public function __construct(ObjectManager $manager, string $classname, bool $asCollection = false) + { + $this->manager = $manager; + $this->asCollection = $asCollection; + $this->classname = $classname; + } + + /** + * @param ArrayCollection|PersistableInterface[]|null $value + * @return string|array + */ + public function transform(mixed $value): string|array + { + if (empty($value)) { + return ''; + } + $ids = []; + /** @var PersistableInterface $entity */ + foreach ($value as $entity) { + $ids[] = $entity->getId(); + } + if ($this->asCollection) { + return $ids; + } + return implode(',', $ids); + } + + /** + * @param string|array|null $value + * @return array|ArrayCollection + */ + public function reverseTransform(mixed $value): array|ArrayCollection + { + if (!$value) { + if ($this->asCollection) { + return new ArrayCollection(); + } + return []; + } + + if (is_array($value)) { + $ids = $value; + } else { + $ids = explode(',', $value); + } + + /** @var array $entities */ + $entities = []; + foreach ($ids as $entityId) { + /** @var PersistableInterface|null $entity */ + $entity = $this->manager + ->getRepository($this->classname) + ->find($entityId) + ; + if (null === $entity) { + throw new TransformationFailedException(sprintf( + 'A %s with id "%s" does not exist!', + $this->classname, + $entityId + )); + } + + $entities[] = $entity; + } + if ($this->asCollection) { + return new ArrayCollection($entities); + } + return $entities; + } +} diff --git a/lib/RoadizCoreBundle/src/Form/DataTransformer/ExplorerProviderItemTransformer.php b/lib/RoadizCoreBundle/src/Form/DataTransformer/ExplorerProviderItemTransformer.php new file mode 100644 index 00000000..6d899917 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Form/DataTransformer/ExplorerProviderItemTransformer.php @@ -0,0 +1,96 @@ +explorerProvider = $explorerProvider; + $this->multiple = $multiple; + $this->useCollection = $useCollection; + } + + /** + * @inheritDoc + */ + public function transform(mixed $value): array|string + { + if (!empty($value) && $this->explorerProvider->supports($value)) { + $item = $this->explorerProvider->toExplorerItem($value); + if (!$item instanceof ExplorerItemInterface) { + throw new TransformationFailedException('Cannot transform model to ExplorerItem.'); + } + return [$item]; + } elseif (!empty($value) && is_iterable($value)) { + $idArray = []; + foreach ($value as $entity) { + if ($this->explorerProvider->supports($entity)) { + $item = $this->explorerProvider->toExplorerItem($entity); + if (!$item instanceof ExplorerItemInterface) { + throw new TransformationFailedException('Cannot transform model to ExplorerItem.'); + } + $idArray[] = $item; + } else { + throw new TransformationFailedException('Cannot transform model to ExplorerItem.'); + } + } + + return array_filter($idArray); + } + return ''; + } + + /** + * @inheritDoc + */ + public function reverseTransform(mixed $value): mixed + { + if (empty($value)) { + $items = []; + } elseif ($value instanceof ExplorerItemInterface) { + $items = [$value]; + } elseif (is_scalar($value)) { + $items = $this->explorerProvider->getItemsById([$value]); + } elseif (\is_array($value) && is_scalar(reset($value))) { + $items = $this->explorerProvider->getItemsById($value); + } elseif (\is_array($value) && reset($value) instanceof ExplorerItemInterface) { + $items = $value; + } else { + throw new TransformationFailedException('Cannot reverse transform submitted data to model.'); + } + + $originals = []; + foreach ($items as $item) { + $originals[] = $item->getOriginal(); + } + + if ($this->multiple) { + if ($this->useCollection) { + return new ArrayCollection(array_filter($originals)); + } + return array_filter($originals); + } + return array_filter($originals)[0] ?? null; + } +} diff --git a/lib/RoadizCoreBundle/src/Form/DataTransformer/FolderCollectionTransformer.php b/lib/RoadizCoreBundle/src/Form/DataTransformer/FolderCollectionTransformer.php new file mode 100644 index 00000000..a234bcac --- /dev/null +++ b/lib/RoadizCoreBundle/src/Form/DataTransformer/FolderCollectionTransformer.php @@ -0,0 +1,23 @@ +nodeTypeField = $nodeTypeField; + $this->entityClassname = $entityClassname; + $this->managerRegistry = $managerRegistry; + } + + /** + * @param mixed $value + * @return mixed + */ + public function transform(mixed $value): mixed + { + /* + * If model is already an PersistableInterface + */ + if ( + !empty($value) && + $value instanceof PersistableInterface + ) { + return $value->getId(); + } elseif (!empty($value) && is_array($value)) { + /* + * If model is a collection of AbstractEntity + */ + $idArray = []; + foreach ($value as $entity) { + if ($entity instanceof PersistableInterface) { + $idArray[] = $entity->getId(); + } + } + return $idArray; + } elseif (!empty($value)) { + return $value; + } + return ''; + } + + /** + * @param mixed $value + * @return mixed + */ + public function reverseTransform(mixed $value): mixed + { + if ($this->nodeTypeField->isManyToMany()) { + $unorderedEntities = $this->managerRegistry->getRepository($this->entityClassname)->findBy([ + 'id' => $value, + ]); + /* + * Need to preserve order in POST data + */ + usort($unorderedEntities, function (PersistableInterface $a, PersistableInterface $b) use ($value) { + return array_search($a->getId(), $value) - + array_search($b->getId(), $value); + }); + return $unorderedEntities; + } + if ($this->nodeTypeField->isManyToOne()) { + return $this->managerRegistry->getRepository($this->entityClassname)->findOneBy([ + 'id' => $value, + ]); + } + return null; + } +} diff --git a/lib/RoadizCoreBundle/src/Form/DataTransformer/NodeTypeTransformer.php b/lib/RoadizCoreBundle/src/Form/DataTransformer/NodeTypeTransformer.php new file mode 100644 index 00000000..afe915d9 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Form/DataTransformer/NodeTypeTransformer.php @@ -0,0 +1,60 @@ +manager = $manager; + } + + /** + * @param NodeType|null $value + * @return int|string + */ + public function transform(mixed $value): int|string + { + if (!$value instanceof NodeType) { + return ''; + } + return $value->getId(); + } + + /** + * @param mixed $value + * @return null|NodeType + */ + public function reverseTransform(mixed $value): ?NodeType + { + if (!$value) { + return null; + } + + $nodeType = $this->manager + ->getRepository(NodeType::class) + ->find($value) + ; + + if (null === $nodeType) { + // causes a validation error + // this message is not shown to the user + // see the invalid_message option + throw new TransformationFailedException(sprintf( + 'A node-type with id "%s" does not exist!', + $value + )); + } + + return $nodeType; + } +} diff --git a/lib/RoadizCoreBundle/src/Form/DataTransformer/PersistableTransformer.php b/lib/RoadizCoreBundle/src/Form/DataTransformer/PersistableTransformer.php new file mode 100644 index 00000000..28a272c3 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Form/DataTransformer/PersistableTransformer.php @@ -0,0 +1,54 @@ + + */ + protected string $doctrineEntity; + private EntityManagerInterface $entityManager; + + /** + * @param EntityManagerInterface $entityManager + * @param class-string $doctrineEntity + */ + public function __construct(EntityManagerInterface $entityManager, string $doctrineEntity) + { + $this->entityManager = $entityManager; + $this->doctrineEntity = $doctrineEntity; + } + + public function transform(mixed $value): mixed + { + if (is_array($value)) { + return array_map(function (PersistableInterface $item) { + return $item->getId(); + }, $value); + } + if ($value instanceof PersistableInterface) { + return $value->getId(); + } + return null; + } + + public function reverseTransform(mixed $value): ?array + { + if (null === $value) { + return null; + } + return $this->entityManager->getRepository($this->doctrineEntity)->findBy([ + 'id' => $value + ]); + } +} diff --git a/lib/RoadizCoreBundle/src/Form/DataTransformer/ProviderDataTransformer.php b/lib/RoadizCoreBundle/src/Form/DataTransformer/ProviderDataTransformer.php new file mode 100644 index 00000000..205f4102 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Form/DataTransformer/ProviderDataTransformer.php @@ -0,0 +1,65 @@ +nodeTypeField = $nodeTypeField; + $this->provider = $provider; + } + + /** + * @param mixed $value + * @return array|null + */ + public function transform(mixed $value): ?array + { + if (null === $value) { + return null; + } + + if (!is_array($value)) { + $value = [$value]; + } + + $value = array_filter($value); + + if (count($value) === 0) { + return null; + } + + return $this->provider->getItemsById($value); + } + + /** + * @param mixed $value + * @return mixed + */ + public function reverseTransform(mixed $value): mixed + { + if ( + is_array($value) && + $this->nodeTypeField->isSingleProvider() && + isset($value[0]) + ) { + return $value[0]; + } + + return $value; + } +} diff --git a/lib/RoadizCoreBundle/src/Form/DataTransformer/ReversePersistableTransformer.php b/lib/RoadizCoreBundle/src/Form/DataTransformer/ReversePersistableTransformer.php new file mode 100644 index 00000000..11f6c497 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Form/DataTransformer/ReversePersistableTransformer.php @@ -0,0 +1,57 @@ + + */ + protected string $doctrineEntity; + /** + * @var EntityManagerInterface + */ + private EntityManagerInterface $entityManager; + + /** + * @param EntityManagerInterface $entityManager + * @param class-string $doctrineEntity + */ + public function __construct(EntityManagerInterface $entityManager, string $doctrineEntity) + { + $this->entityManager = $entityManager; + $this->doctrineEntity = $doctrineEntity; + } + + public function transform(mixed $value): ?array + { + if (null === $value) { + return null; + } + return $this->entityManager->getRepository($this->doctrineEntity)->findBy([ + 'id' => $value + ]); + } + + public function reverseTransform(mixed $value): mixed + { + if (is_array($value)) { + return array_map(function (PersistableInterface $item) { + return $item->getId(); + }, $value); + } + if ($value instanceof PersistableInterface) { + return $value->getId(); + } + return null; + } +} diff --git a/lib/RoadizCoreBundle/src/Form/DataTransformer/TagTranslationDocumentsTransformer.php b/lib/RoadizCoreBundle/src/Form/DataTransformer/TagTranslationDocumentsTransformer.php new file mode 100644 index 00000000..ad9b53f9 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Form/DataTransformer/TagTranslationDocumentsTransformer.php @@ -0,0 +1,88 @@ +manager = $manager; + $this->tagTranslation = $tagTranslation; + } + + /** + * Transform TagTranslationDocuments join entities + * to Document entities for displaying in document VueJS component. + * + * @param TagTranslationDocuments[]|null $value + * @return Document[] + */ + public function transform(mixed $value): array + { + if (empty($value)) { + return []; + } + $documents = []; + foreach ($value as $tagTranslationDocument) { + $documents[] = $tagTranslationDocument->getDocument(); + } + + return $documents; + } + + /** + * @param array $value + * @return ArrayCollection + */ + public function reverseTransform(mixed $value): ArrayCollection + { + if (!$value) { + return new ArrayCollection(); + } + + $documents = new ArrayCollection(); + $position = 0; + foreach ($value as $documentId) { + $document = $this->manager + ->getRepository(Document::class) + ->find($documentId) + ; + if (null === $document) { + throw new TransformationFailedException(sprintf( + 'A document with id "%s" does not exist!', + $documentId + )); + } + + $ttd = new TagTranslationDocuments($this->tagTranslation, $document); + $ttd->setPosition($position); + $this->manager->persist($ttd); + $documents->add($ttd); + + $position++; + } + + return $documents; + } +} diff --git a/lib/RoadizCoreBundle/src/Form/DataTransformer/TranslationTransformer.php b/lib/RoadizCoreBundle/src/Form/DataTransformer/TranslationTransformer.php new file mode 100644 index 00000000..a28baf6b --- /dev/null +++ b/lib/RoadizCoreBundle/src/Form/DataTransformer/TranslationTransformer.php @@ -0,0 +1,62 @@ +managerRegistry = $managerRegistry; + } + + /** + * @param Translation|null $value + * @return int|string + */ + public function transform(mixed $value): int|string + { + if (!($value instanceof PersistableInterface)) { + return ''; + } + return $value->getId(); + } + + /** + * @param mixed $value + * @return null|Translation + */ + public function reverseTransform(mixed $value): ?Translation + { + if (!$value) { + return null; + } + + /** @var Translation|null $translation */ + $translation = $this->managerRegistry + ->getRepository(Translation::class) + ->find($value) + ; + + if (null === $translation) { + // causes a validation error + // this message is not shown to the user + // see the invalid_message option + throw new TransformationFailedException(sprintf( + 'A translation with id "%s" does not exist!', + $value + )); + } + + return $translation; + } +} diff --git a/lib/RoadizCoreBundle/src/Form/DocumentCollectionType.php b/lib/RoadizCoreBundle/src/Form/DocumentCollectionType.php new file mode 100644 index 00000000..be057e78 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Form/DocumentCollectionType.php @@ -0,0 +1,67 @@ +managerRegistry = $managerRegistry; + } + + /** + * {@inheritdoc} + */ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder->addModelTransformer(new DocumentCollectionTransformer( + $this->managerRegistry->getManagerForClass(Document::class), + true + )); + } + + /** + * {@inheritdoc} + */ + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'label' => false, + 'required' => false, + 'class' => Document::class, + 'multiple' => true, + 'property' => 'id', + ]); + } + + /** + * {@inheritdoc} + */ + public function getParent(): ?string + { + return TextType::class; + } + + /** + * {@inheritdoc} + */ + public function getBlockPrefix(): string + { + return 'documents'; + } +} diff --git a/lib/RoadizCoreBundle/src/Form/EnumerationType.php b/lib/RoadizCoreBundle/src/Form/EnumerationType.php new file mode 100644 index 00000000..d97578a1 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Form/EnumerationType.php @@ -0,0 +1,67 @@ +setDefaults([ + 'strict' => true, + 'multiple' => false, + 'placeholder' => 'choose.value', + ]); + + $resolver->setRequired(['nodeTypeField']); + $resolver->setAllowedTypes('nodeTypeField', [NodeTypeField::class]); + + $resolver->setNormalizer('choices', function (Options $options, $choices) { + $values = explode(',', $options['nodeTypeField']->getDefaultValues() ?? ''); + + foreach ($values as $value) { + $value = trim($value); + $choices[$value] = $value; + } + return $choices; + }); + + $resolver->setNormalizer('placeholder', function (Options $options, $placeholder) { + if ('' !== $options['nodeTypeField']->getPlaceholder()) { + $placeholder = $options['nodeTypeField']->getPlaceholder(); + } + return $placeholder; + }); + + $resolver->setNormalizer('expanded', function (Options $options, $expanded) { + return $options['nodeTypeField']->isExpanded(); + }); + } + /** + * {@inheritdoc} + */ + public function getParent(): ?string + { + return ChoiceType::class; + } + /** + * {@inheritdoc} + */ + public function getBlockPrefix(): string + { + return 'enumeration'; + } +} diff --git a/lib/RoadizCoreBundle/src/Form/Error/FormErrorSerializer.php b/lib/RoadizCoreBundle/src/Form/Error/FormErrorSerializer.php new file mode 100644 index 00000000..6c7b062e --- /dev/null +++ b/lib/RoadizCoreBundle/src/Form/Error/FormErrorSerializer.php @@ -0,0 +1,59 @@ +translator = $translator; + } + + public function getErrorsAsArray(FormInterface $form): array + { + $errors = []; + /** @var FormError $error */ + foreach ($form->getErrors() as $error) { + if (null !== $error->getOrigin()) { + $errorFieldName = $error->getOrigin()->getName(); + if (count($error->getMessageParameters()) > 0) { + $errors[$errorFieldName] = $this->translator->trans($error->getMessageTemplate(), $error->getMessageParameters()); + } else { + $errors[$errorFieldName] = $this->translator->trans($error->getMessage()); + } + $cause = $error->getCause(); + if (null !== $cause) { + if ($cause instanceof ConstraintViolation) { + $cause = $cause->getCause(); + } + if (is_object($cause)) { + if ($cause instanceof \Exception) { + $errors[$errorFieldName . '_cause_message'] = $cause->getMessage(); + } + $errors[$errorFieldName . '_cause'] = get_class($cause); + } + } + } + } + + foreach ($form->all() as $key => $child) { + $err = $this->getErrorsAsArray($child); + if ($err) { + $errors[$key] = $err; + } + } + return $errors; + } +} diff --git a/lib/RoadizCoreBundle/src/Form/Error/FormErrorSerializerInterface.php b/lib/RoadizCoreBundle/src/Form/Error/FormErrorSerializerInterface.php new file mode 100644 index 00000000..46a883cb --- /dev/null +++ b/lib/RoadizCoreBundle/src/Form/Error/FormErrorSerializerInterface.php @@ -0,0 +1,12 @@ +addModelTransformer(new ExplorerProviderItemTransformer( + $options['explorerProvider'], + $options['asMultiple'], + $options['useCollection'], + )); + } + + /** + * Pass data to form twig template. + * + * @param FormView $view + * @param FormInterface $form + * @param array $options + */ + public function buildView(FormView $view, FormInterface $form, array $options): void + { + parent::buildView($view, $form, $options); + + if ($options['max_length'] > 0) { + $view->vars['attr']['data-max-length'] = $options['max_length']; + } + if ($options['min_length'] > 0) { + $view->vars['attr']['data-min-length'] = $options['min_length']; + } + if ($options['asMultiple'] === false) { + $view->vars['attr']['data-max-length'] = 1; + } + + $view->vars['provider_class'] = get_class($options['explorerProvider']); + } + + /** + * {@inheritdoc} + */ + public function getBlockPrefix(): string + { + return 'explorer_provider'; + } + + /** + * @inheritDoc + */ + public function configureOptions(OptionsResolver $resolver): void + { + parent::configureOptions($resolver); + + $resolver->setRequired('explorerProvider'); + + $resolver->setDefault('max_length', 0); + $resolver->setDefault('min_length', 0); + $resolver->setDefault('multiple', true); + $resolver->setDefault('asMultiple', true); + $resolver->setDefault('useCollection', false); + + $resolver->setAllowedTypes('explorerProvider', [ExplorerProviderInterface::class]); + $resolver->setAllowedTypes('max_length', ['int']); + $resolver->setAllowedTypes('min_length', ['int']); + $resolver->setAllowedTypes('asMultiple', ['bool']); + $resolver->setAllowedTypes('useCollection', ['bool']); + } + + /** + * {@inheritdoc} + */ + public function getParent(): ?string + { + return HiddenType::class; + } +} diff --git a/lib/RoadizCoreBundle/src/Form/ExtendedBooleanType.php b/lib/RoadizCoreBundle/src/Form/ExtendedBooleanType.php new file mode 100644 index 00000000..8bacddc1 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Form/ExtendedBooleanType.php @@ -0,0 +1,45 @@ +setDefaults([ + 'choices' => [ + 'true' => true, + 'false' => false, + ], + 'placeholder' => 'ignore', + 'required' => false, + 'expanded' => true, + ]); + } + /** + * {@inheritdoc} + */ + public function getParent(): ?string + { + return ChoiceType::class; + } + /** + * {@inheritdoc} + */ + public function getBlockPrefix(): string + { + return 'extendedboolean'; + } +} diff --git a/lib/RoadizCoreBundle/src/Form/Extension/HelpAndGroupExtension.php b/lib/RoadizCoreBundle/src/Form/Extension/HelpAndGroupExtension.php new file mode 100644 index 00000000..ed43ee07 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Form/Extension/HelpAndGroupExtension.php @@ -0,0 +1,36 @@ +vars['help'] = $options['help'] ?? ''; + $view->vars['group'] = $options['group'] ?? ''; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'help' => null, + 'group' => null, + ]); + + $resolver->setAllowedTypes('help', ['null', 'string']); + $resolver->setAllowedTypes('group', ['null', 'string']); + } +} diff --git a/lib/RoadizCoreBundle/src/Form/GroupsType.php b/lib/RoadizCoreBundle/src/Form/GroupsType.php new file mode 100644 index 00000000..06cbaa8d --- /dev/null +++ b/lib/RoadizCoreBundle/src/Form/GroupsType.php @@ -0,0 +1,100 @@ +authorizationChecker = $authorizationChecker; + $this->managerRegistry = $managerRegistry; + } + + /** + * @inheritDoc + */ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder->addModelTransformer(new CallbackTransformer(function ($modelToForm) { + if (null !== $modelToForm) { + if ($modelToForm instanceof Collection) { + $modelToForm = $modelToForm->toArray(); + } + return array_map(function (Group $group) { + return $group->getId(); + }, $modelToForm); + } + return null; + }, function ($formToModels) { + if (null === $formToModels || (is_array($formToModels) && count($formToModels) === 0)) { + return []; + } + return $this->managerRegistry->getRepository(Group::class)->findBy([ + 'id' => $formToModels + ]); + })); + } + + + /** + * {@inheritdoc} + */ + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([]); + + /* + * Use normalizer to populate choices from ChoiceType + */ + $resolver->setNormalizer('choices', function (Options $options, $choices) { + $groups = $this->managerRegistry->getRepository(Group::class)->findAll(); + + /** @var Group $group */ + foreach ($groups as $group) { + if ($this->authorizationChecker->isGranted($group)) { + $choices[$group->getName()] = $group->getId(); + } + } + return $choices; + }); + } + /** + * {@inheritdoc} + */ + public function getParent(): ?string + { + return ChoiceType::class; + } + /** + * {@inheritdoc} + */ + public function getBlockPrefix(): string + { + return 'groups'; + } +} diff --git a/lib/RoadizCoreBundle/src/Form/HoneypotType.php b/lib/RoadizCoreBundle/src/Form/HoneypotType.php new file mode 100644 index 00000000..215128f8 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Form/HoneypotType.php @@ -0,0 +1,68 @@ +vars['attr'] = [ + 'autocomplete' => 'nope', + 'tabindex' => -1, + 'style' => 'position: fixed; left: -100vw; top: -100vh;' + ]; + } + + /** + * @inheritDoc + */ + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'label' => false, + 'required' => false, + 'mapped' => false, + 'data' => '', + ]); + } + + /** + * @inheritDoc + */ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder->addEventListener(FormEvents::PRE_SUBMIT, function (FormEvent $event) { + $data = $event->getData(); + $form = $event->getForm(); + if (!$data) { + return; + } + $form->getParent()->addError(new FormError('form_has_errors.check_you_fields')); + }); + } +} diff --git a/lib/RoadizCoreBundle/src/Form/JsonType.php b/lib/RoadizCoreBundle/src/Form/JsonType.php new file mode 100644 index 00000000..6350208f --- /dev/null +++ b/lib/RoadizCoreBundle/src/Form/JsonType.php @@ -0,0 +1,53 @@ +vars['attr']['class'] = 'json_textarea'; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'required' => false, + 'constraints' => [ + new ValidJson() + ] + ]); + } +} diff --git a/lib/RoadizCoreBundle/src/Form/LoginRequestForm.php b/lib/RoadizCoreBundle/src/Form/LoginRequestForm.php new file mode 100644 index 00000000..7d7ccf99 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Form/LoginRequestForm.php @@ -0,0 +1,36 @@ +add('email', EmailType::class, [ + 'required' => true, + 'label' => 'your.account.email', + 'constraints' => [ + new Email([ + 'message' => 'email.invalid', + ]), + new ValidAccountEmail(), + ], + ]); + } + + /** + * {@inheritdoc} + */ + public function getBlockPrefix(): string + { + return 'login_request'; + } +} diff --git a/lib/RoadizCoreBundle/src/Form/LoginResetForm.php b/lib/RoadizCoreBundle/src/Form/LoginResetForm.php new file mode 100644 index 00000000..d9bfc73e --- /dev/null +++ b/lib/RoadizCoreBundle/src/Form/LoginResetForm.php @@ -0,0 +1,58 @@ +add('token', HiddenType::class, [ + 'required' => true, + 'data' => $options['token'], + 'label' => false, + 'constraints' => [ + new ValidAccountConfirmationToken([ + 'ttl' => $options['confirmationTtl'], + 'message' => 'confirmation.token.is.invalid', + 'expiredMessage' => 'confirmation.token.has.expired', + ]), + ], + ]) + ->add('plainPassword', CreatePasswordType::class, [ + 'required' => true, + ]); + } + + /** + * {@inheritdoc} + */ + public function getBlockPrefix(): string + { + return 'login_reset'; + } + + /** + * {@inheritdoc} + */ + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setRequired([ + 'token', + 'confirmationTtl', + ]); + + $resolver->setAllowedTypes('token', ['string']); + $resolver->setAllowedTypes('confirmationTtl', ['int']); + } +} diff --git a/lib/RoadizCoreBundle/src/Form/MarkdownType.php b/lib/RoadizCoreBundle/src/Form/MarkdownType.php new file mode 100644 index 00000000..f2d90190 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Form/MarkdownType.php @@ -0,0 +1,116 @@ +vars['attr']['class'] = 'markdown_textarea'; + $view->vars['attr']['allow_h2'] = $options['allow_h2']; + $view->vars['attr']['allow_h3'] = $options['allow_h3']; + $view->vars['attr']['allow_h4'] = $options['allow_h4']; + $view->vars['attr']['allow_h5'] = $options['allow_h5']; + $view->vars['attr']['allow_h6'] = $options['allow_h6']; + $view->vars['attr']['allow_bold'] = $options['allow_bold']; + $view->vars['attr']['allow_italic'] = $options['allow_italic']; + $view->vars['attr']['allow_blockquote'] = $options['allow_blockquote']; + $view->vars['attr']['allow_list'] = $options['allow_list']; + $view->vars['attr']['allow_nbsp'] = $options['allow_nbsp']; + $view->vars['attr']['allow_nb_hyphen'] = $options['allow_nb_hyphen']; + $view->vars['attr']['allow_image'] = $options['allow_image']; + $view->vars['attr']['allow_return'] = $options['allow_return']; + $view->vars['attr']['allow_link'] = $options['allow_link']; + $view->vars['attr']['allow_hr'] = $options['allow_hr']; + $view->vars['attr']['allow_preview'] = $options['allow_preview']; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'required' => false, + 'allow_h2' => true, + 'allow_h3' => true, + 'allow_h4' => true, + 'allow_h5' => true, + 'allow_h6' => true, + 'allow_bold' => true, + 'allow_italic' => true, + 'allow_blockquote' => true, + 'allow_image' => false, + 'allow_list' => true, + 'allow_nbsp' => true, + 'allow_nb_hyphen' => true, + 'allow_return' => true, + 'allow_link' => true, + 'allow_hr' => true, + 'allow_preview' => true, + ]); + + $resolver->setAllowedTypes('allow_h2', ['boolean']); + $resolver->setAllowedTypes('allow_h3', ['boolean']); + $resolver->setAllowedTypes('allow_h4', ['boolean']); + $resolver->setAllowedTypes('allow_h5', ['boolean']); + $resolver->setAllowedTypes('allow_h6', ['boolean']); + $resolver->setAllowedTypes('allow_bold', ['boolean']); + $resolver->setAllowedTypes('allow_italic', ['boolean']); + $resolver->setAllowedTypes('allow_blockquote', ['boolean']); + $resolver->setAllowedTypes('allow_image', ['boolean']); + $resolver->setAllowedTypes('allow_list', ['boolean']); + $resolver->setAllowedTypes('allow_nbsp', ['boolean']); + $resolver->setAllowedTypes('allow_nb_hyphen', ['boolean']); + $resolver->setAllowedTypes('allow_return', ['boolean']); + $resolver->setAllowedTypes('allow_link', ['boolean']); + $resolver->setAllowedTypes('allow_hr', ['boolean']); + $resolver->setAllowedTypes('allow_preview', ['boolean']); + } +} diff --git a/lib/RoadizCoreBundle/src/Form/MultipleEnumerationType.php b/lib/RoadizCoreBundle/src/Form/MultipleEnumerationType.php new file mode 100644 index 00000000..aefdecf2 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Form/MultipleEnumerationType.php @@ -0,0 +1,66 @@ +setDefaults([ + 'strict' => true, + 'multiple' => true, + ]); + + $resolver->setRequired(['nodeTypeField']); + $resolver->setAllowedTypes('nodeTypeField', [NodeTypeField::class]); + + $resolver->setNormalizer('placeholder', function (Options $options, $placeholder) { + if ('' !== $options['nodeTypeField']->getPlaceholder()) { + $placeholder = $options['nodeTypeField']->getPlaceholder(); + } + return $placeholder; + }); + + $resolver->setNormalizer('choices', function (Options $options, $choices) { + $values = explode(',', $options['nodeTypeField']->getDefaultValues() ?? ''); + + foreach ($values as $value) { + $value = trim($value); + $choices[$value] = $value; + } + return $choices; + }); + + $resolver->setNormalizer('expanded', function (Options $options, $expanded) { + return $options['nodeTypeField']->isExpanded(); + }); + } + /** + * {@inheritdoc} + */ + public function getParent(): ?string + { + return ChoiceType::class; + } + /** + * {@inheritdoc} + */ + public function getBlockPrefix(): string + { + return 'enumeration'; + } +} diff --git a/lib/RoadizCoreBundle/src/Form/NodeStatesType.php b/lib/RoadizCoreBundle/src/Form/NodeStatesType.php new file mode 100644 index 00000000..50c2cbac --- /dev/null +++ b/lib/RoadizCoreBundle/src/Form/NodeStatesType.php @@ -0,0 +1,48 @@ +setDefaults([ + 'choices' => $choices, + 'placeholder' => 'ignore', + ]); + } + /** + * {@inheritdoc} + */ + public function getParent(): ?string + { + return ChoiceType::class; + } + /** + * {@inheritdoc} + */ + public function getBlockPrefix(): string + { + return 'node_statuses'; + } +} diff --git a/lib/RoadizCoreBundle/src/Form/NodeTypesType.php b/lib/RoadizCoreBundle/src/Form/NodeTypesType.php new file mode 100644 index 00000000..6c340b31 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Form/NodeTypesType.php @@ -0,0 +1,68 @@ +managerRegistry = $managerRegistry; + } + + /** + * {@inheritdoc} + */ + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'showInvisible' => false, + ]); + $resolver->setAllowedTypes('showInvisible', ['boolean']); + $resolver->setNormalizer('choices', function (Options $options, $choices) { + $criteria = []; + if ($options['showInvisible'] === false) { + $criteria['visible'] = true; + } + $nodeTypes = $this->managerRegistry->getRepository(NodeType::class)->findBy($criteria); + + /** @var NodeType $nodeType */ + foreach ($nodeTypes as $nodeType) { + $choices[$nodeType->getDisplayName()] = $nodeType->getId(); + } + ksort($choices); + + return $choices; + }); + } + /** + * {@inheritdoc} + */ + public function getParent(): ?string + { + return ChoiceType::class; + } + /** + * {@inheritdoc} + */ + public function getBlockPrefix(): string + { + return 'node_types'; + } +} diff --git a/lib/RoadizCoreBundle/src/Form/NodesType.php b/lib/RoadizCoreBundle/src/Form/NodesType.php new file mode 100644 index 00000000..e3d450d5 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Form/NodesType.php @@ -0,0 +1,112 @@ +managerRegistry = $managerRegistry; + } + + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder->addModelTransformer(new CallbackTransformer(function ($mixedEntities) { + if ($mixedEntities instanceof Collection) { + return $mixedEntities->toArray(); + } + if (!is_array($mixedEntities)) { + return [$mixedEntities]; + } + return $mixedEntities; + }, function ($mixedIds) use ($options) { + /** @var NodeRepository $repository */ + $repository = $this->managerRegistry + ->getRepository(Node::class) + ->setDisplayingAllNodesStatuses(true); + if (\is_array($mixedIds) && count($mixedIds) === 0) { + return []; + } elseif (\is_array($mixedIds)) { + if ($options['multiple'] === false) { + return $repository->findOneBy(['id' => $mixedIds]); + } + return $repository->findBy(['id' => $mixedIds]); + } elseif ($options['multiple'] === true) { + return []; + } else { + return $repository->findOneById($mixedIds); + } + })); + } + + /** + * {@inheritdoc} + * + * @param OptionsResolver $resolver + */ + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'multiple' => true, + 'nodes' => [], + ]); + + $resolver->setAllowedTypes('multiple', ['boolean']); + } + + /** + * {@inheritdoc} + * + * @param FormView $view + * @param FormInterface $form + * @param array $options + */ + public function finishView(FormView $view, FormInterface $form, array $options): void + { + parent::finishView($view, $form, $options); + + /* + * Inject data as plain nodes entities + */ + if (!empty($options['nodes'])) { + $view->vars['data'] = $options['nodes']; + } + } + /** + * {@inheritdoc} + */ + public function getParent(): ?string + { + return HiddenType::class; + } + + /** + * @inheritDoc + */ + public function getBlockPrefix(): string + { + return 'nodes'; + } +} diff --git a/lib/RoadizCoreBundle/src/Form/RealmChoiceType.php b/lib/RoadizCoreBundle/src/Form/RealmChoiceType.php new file mode 100644 index 00000000..a6f466fe --- /dev/null +++ b/lib/RoadizCoreBundle/src/Form/RealmChoiceType.php @@ -0,0 +1,57 @@ +managerRegistry = $managerRegistry; + } + + /** + * {@inheritdoc} + */ + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'multiple' => false, + 'choice_label' => function (?Realm $choice) { + return $choice ? $choice->getName() : ''; + }, + 'choice_value' => function (?Realm $choice) { + return $choice ? $choice->getId() : ''; + }, + ]); + + $resolver->setNormalizer('choices', function () { + return $this->managerRegistry->getRepository(Realm::class)->findAll(); + }); + } + + /** + * {@inheritdoc} + */ + public function getParent(): ?string + { + return ChoiceType::class; + } + + /** + * {@inheritdoc} + */ + public function getBlockPrefix(): string + { + return 'realms'; + } +} diff --git a/lib/RoadizCoreBundle/src/Form/RealmNodeType.php b/lib/RoadizCoreBundle/src/Form/RealmNodeType.php new file mode 100644 index 00000000..44fcf645 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Form/RealmNodeType.php @@ -0,0 +1,39 @@ +add('realm', RealmChoiceType::class, [ + 'label' => 'realm_node.realm', + 'help' => 'realm_node.realm.help', + 'placeholder' => 'realm_node.realm.placeholder', + 'required' => false, + ])->add('inheritanceType', ChoiceType::class, [ + 'label' => 'realm_node.inheritanceType', + 'help' => 'realm_node.inheritanceType.help', + 'required' => true, + 'choices' => [ + 'realm_node.' . RealmInterface::INHERITANCE_ROOT => RealmInterface::INHERITANCE_ROOT, + 'realm_node.' . RealmInterface::INHERITANCE_AUTO => RealmInterface::INHERITANCE_AUTO, + 'realm_node.' . RealmInterface::INHERITANCE_NONE => RealmInterface::INHERITANCE_NONE, + ] + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefault('data_class', RealmNode::class); + } +} diff --git a/lib/RoadizCoreBundle/src/Form/RealmType.php b/lib/RoadizCoreBundle/src/Form/RealmType.php new file mode 100644 index 00000000..def44935 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Form/RealmType.php @@ -0,0 +1,74 @@ +add('name', TextType::class, [ + 'label' => 'name', + 'empty_data' => '', + 'by_reference' => true, + 'required' => true, + ])->add('type', ChoiceType::class, [ + 'label' => 'realm.type', + 'help' => 'realm.type.help', + 'required' => true, + 'choices' => [ + 'realm.' . RealmInterface::TYPE_PLAIN_PASSWORD => RealmInterface::TYPE_PLAIN_PASSWORD, + 'realm.' . RealmInterface::TYPE_ROLE => RealmInterface::TYPE_ROLE, + 'realm.' . RealmInterface::TYPE_USER => RealmInterface::TYPE_USER, + ] + ])->add('behaviour', ChoiceType::class, [ + 'label' => 'realm.behaviour', + 'help' => 'realm.behaviour.help', + 'required' => true, + 'choices' => [ + 'realm.behaviour_' . RealmInterface::BEHAVIOUR_NONE => RealmInterface::BEHAVIOUR_NONE, + 'realm.behaviour_' . RealmInterface::BEHAVIOUR_DENY => RealmInterface::BEHAVIOUR_DENY, + 'realm.behaviour_' . RealmInterface::BEHAVIOUR_HIDE_BLOCKS => RealmInterface::BEHAVIOUR_HIDE_BLOCKS, + ] + ])->add('plainPassword', TextType::class, [ + 'label' => 'realm.plainPassword', + 'help' => 'realm.plainPassword.help', + 'empty_data' => null, + 'required' => false, + ])->add('serializationGroup', TextType::class, [ + 'label' => 'realm.serializationGroup', + 'help' => 'realm.serializationGroup.help', + 'empty_data' => null, + 'by_reference' => true, + 'required' => false, + ])->add('roleEntity', RoleEntityType::class, [ + 'label' => 'realm.role', + 'help' => 'realm.role.help', + 'required' => false, + 'placeholder' => 'realm.role.placeholder', + ])->add('users', UserCollectionType::class, [ + 'label' => 'realm.users', + 'help' => 'realm.users.help', + 'required' => false, + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefault('data_class', Realm::class); + $resolver->setDefault('constraints', [ + new UniqueEntity(['name']), + new UniqueEntity(['serializationGroup']) + ]); + } +} diff --git a/lib/RoadizCoreBundle/src/Form/RecaptchaType.php b/lib/RoadizCoreBundle/src/Form/RecaptchaType.php new file mode 100644 index 00000000..a44912b7 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Form/RecaptchaType.php @@ -0,0 +1,65 @@ + + * @since 1.0 + */ +class RecaptchaType extends AbstractType +{ + /** + * (non-PHPdoc) + * @see \Symfony\Component\Form\AbstractType::buildView() + * @param FormView $view + * @param FormInterface $form + * @param array $options + */ + public function buildView(FormView $view, FormInterface $form, array $options): void + { + $view->vars['configs'] = $options['configs']; + } + + /** + * @see \Symfony\Component\Form\AbstractType::configureOptions() + * @param OptionsResolver $resolver + */ + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'configs' => [ + 'publicKey' => '' + ], + ]); + } + + /** + * @see \Symfony\Component\Form\AbstractType::getParent() + */ + public function getParent(): ?string + { + return TextType::class; + } + + /** + * @see \Symfony\Component\Form\FormTypeInterface::getName() + * + * {% block recaptcha_widget -%} + *
+ * {%- endblock recaptcha_widget %} + */ + public function getBlockPrefix(): string + { + return 'recaptcha'; + } +} diff --git a/lib/RoadizCoreBundle/src/Form/RoleEntityType.php b/lib/RoadizCoreBundle/src/Form/RoleEntityType.php new file mode 100644 index 00000000..6e0a4cf9 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Form/RoleEntityType.php @@ -0,0 +1,69 @@ +managerRegistry = $managerRegistry; + $this->security = $security; + } + + /** + * {@inheritdoc} + */ + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'multiple' => false, + 'choice_label' => function (?Role $choice) { + return $choice ? $choice->getRole() : ''; + }, + 'choice_value' => function (?Role $choice) { + return $choice ? $choice->getId() : ''; + }, + ]); + + $resolver->setNormalizer('choices', function (Options $options, $choices) { + $roles = $this->managerRegistry->getRepository(Role::class)->findAll(); + + /** @var Role $role */ + foreach ($roles as $role) { + if ($this->security->isGranted($role->getRole())) { + $choices[] = $role; + } + } + return $choices; + }); + } + + /** + * {@inheritdoc} + */ + public function getParent(): ?string + { + return ChoiceType::class; + } + + /** + * {@inheritdoc} + */ + public function getBlockPrefix(): string + { + return 'role_entity'; + } +} diff --git a/lib/RoadizCoreBundle/src/Form/RolesType.php b/lib/RoadizCoreBundle/src/Form/RolesType.php new file mode 100644 index 00000000..be9cfecf --- /dev/null +++ b/lib/RoadizCoreBundle/src/Form/RolesType.php @@ -0,0 +1,82 @@ +authorizationChecker = $authorizationChecker; + $this->managerRegistry = $managerRegistry; + } + + /** + * {@inheritdoc} + */ + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'roles' => new ArrayCollection(), + 'multiple' => false, + ]); + + $resolver->setAllowedTypes('multiple', ['bool']); + $resolver->setAllowedTypes('roles', [Collection::class]); + + /* + * Use normalizer to populate choices from ChoiceType + */ + $resolver->setNormalizer('choices', function (Options $options, $choices) { + $roles = $this->managerRegistry->getRepository(Role::class)->findAll(); + + /** @var Role $role */ + foreach ($roles as $role) { + if ( + $this->authorizationChecker->isGranted($role->getRole()) && + !$options['roles']->contains($role) + ) { + $choices[$role->getRole()] = $role->getId(); + } + } + return $choices; + }); + } + /** + * {@inheritdoc} + */ + public function getParent(): ?string + { + return ChoiceType::class; + } + /** + * {@inheritdoc} + */ + public function getBlockPrefix(): string + { + return 'roles'; + } +} diff --git a/lib/RoadizCoreBundle/src/Form/SeparatorType.php b/lib/RoadizCoreBundle/src/Form/SeparatorType.php new file mode 100644 index 00000000..f3c05734 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Form/SeparatorType.php @@ -0,0 +1,31 @@ +setDefaults([ + 'required' => false + ]); + } + /** + * {@inheritdoc} + */ + public function getBlockPrefix(): string + { + return 'separator'; + } +} diff --git a/lib/RoadizCoreBundle/src/Form/SettingDocumentType.php b/lib/RoadizCoreBundle/src/Form/SettingDocumentType.php new file mode 100644 index 00000000..ce4ddd9f --- /dev/null +++ b/lib/RoadizCoreBundle/src/Form/SettingDocumentType.php @@ -0,0 +1,82 @@ +documentFactory = $documentFactory; + $this->managerRegistry = $managerRegistry; + $this->documentsStorage = $documentsStorage; + } + + /** + * {@inheritdoc} + */ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder->addModelTransformer(new CallbackTransformer( + function ($value) { + if (null !== $value) { + $manager = $this->managerRegistry->getManagerForClass(Document::class); + /** @var Document|null $document */ + $document = $manager->find(Document::class, $value); + if (null !== $document) { + // transform the array to a string + return new File($this->documentsStorage->publicUrl($document->getMountPath()), false); + } + } + return null; + }, + function ($file) { + if ($file instanceof UploadedFile && $file->isValid()) { + $this->documentFactory->setFile($file); + $document = $this->documentFactory->getDocument(); + + if ($document instanceof Document) { + $manager = $this->managerRegistry->getManagerForClass(Document::class); + $manager->persist($document); + $manager->flush(); + + return $document->getId(); + } + } + return null; + } + )); + } + + /** + * {@inheritdoc} + */ + public function getParent(): ?string + { + return FileType::class; + } +} diff --git a/lib/RoadizCoreBundle/src/Form/SettingGroupType.php b/lib/RoadizCoreBundle/src/Form/SettingGroupType.php new file mode 100644 index 00000000..da3b5412 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Form/SettingGroupType.php @@ -0,0 +1,90 @@ +managerRegistry = $managerRegistry; + } + + /** + * @inheritDoc + */ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder->addModelTransformer(new CallbackTransformer( + function (SettingGroup $settingGroup = null) { + if (null !== $settingGroup) { + // transform the array to a string + return $settingGroup->getId(); + } + return null; + }, + function ($id) { + if (null !== $id) { + $manager = $this->managerRegistry->getManagerForClass(SettingGroup::class); + return $manager->find(SettingGroup::class, $id); + } + return null; + } + )); + } + + /** + * {@inheritdoc} + */ + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'choices' => [], + 'placeholder' => '---------', + ]); + + /* + * Use normalizer to populate choices from ChoiceType + */ + $resolver->setNormalizer('choices', function (Options $options, $choices) { + $groups = $this->managerRegistry->getRepository(SettingGroup::class)->findAll(); + /** @var SettingGroup $group */ + foreach ($groups as $group) { + $choices[$group->getName()] = $group->getId(); + } + return $choices; + }); + } + /** + * {@inheritdoc} + */ + public function getParent(): ?string + { + return ChoiceType::class; + } + /** + * {@inheritdoc} + */ + public function getBlockPrefix(): string + { + return 'setting_groups'; + } +} diff --git a/lib/RoadizCoreBundle/src/Form/SettingType.php b/lib/RoadizCoreBundle/src/Form/SettingType.php new file mode 100644 index 00000000..cd5b2613 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Form/SettingType.php @@ -0,0 +1,179 @@ +settingTypeResolver = $settingTypeResolver; + } + + /** + * {@inheritdoc} + */ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + if ($options['shortEdit'] === false) { + $builder + ->add('name', TextType::class, [ + 'empty_data' => '', + 'label' => 'name', + ]) + ->add('description', MarkdownType::class, [ + 'label' => 'description', + 'required' => false, + ]) + ->add('visible', CheckboxType::class, [ + 'label' => 'visible', + 'required' => false, + ]) + ->add('encrypted', CheckboxType::class, [ + 'label' => 'encrypted', + 'required' => false, + ]) + ->add('type', ChoiceType::class, [ + 'label' => 'type', + 'required' => true, + 'choices' => array_flip(Setting::$typeToHuman), + ]) + ->add('settingGroup', SettingGroupType::class, [ + 'label' => 'setting.group', + 'required' => false, + ]) + ->add('defaultValues', TextType::class, [ + 'label' => 'defaultValues', + 'attr' => [ + 'placeholder' => 'enter_values_comma_separated', + ], + 'required' => false, + ]) + ; + } + + $builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) use ($options) { + /** @var Setting|null $setting */ + $setting = $event->getData(); + $form = $event->getForm(); + + if ($setting instanceof Setting) { + if ($setting->getType() === AbstractField::DOCUMENTS_T) { + $form->add( + 'value', + SettingDocumentType::class, + [ + 'label' => (!$options['shortEdit']) ? 'value' : false, + 'required' => false, + ] + ); + } else { + $form->add( + 'value', + $this->settingTypeResolver->getSettingType($setting), + $this->getFormOptionsForSetting($setting, $options['shortEdit']) + ); + } + } else { + $form->add('value', TextType::class, [ + 'label' => 'value', + 'required' => false, + ]); + } + }); + } + + /** + * {@inheritdoc} + */ + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefault('data_class', Setting::class); + $resolver->setDefault('shortEdit', false); + $resolver->setAllowedTypes('shortEdit', ['boolean']); + } + + protected function getFormOptionsForSetting(Setting $setting, bool $shortEdit = false): array + { + $label = (!$shortEdit) ? 'value' : false; + + switch ($setting->getType()) { + case AbstractField::ENUM_T: + case AbstractField::MULTIPLE_T: + $values = explode(',', $setting->getDefaultValues() ?? ''); + $values = array_map(function ($item) { + return trim($item); + }, $values); + return [ + 'label' => $label, + 'placeholder' => 'choose.value', + 'required' => false, + 'choices' => array_combine($values, $values), + 'multiple' => $setting->getType() === AbstractField::MULTIPLE_T + ]; + case AbstractField::EMAIL_T: + return [ + 'label' => $label, + 'required' => false, + 'constraints' => [ + new Email(), + ] + ]; + case AbstractField::DATETIME_T: + return [ + 'placeholder' => [ + 'hour' => 'hour', + 'minute' => 'minute', + ], + 'date_widget' => 'single_text', + 'date_format' => 'yyyy-MM-dd', + 'attr' => [ + 'class' => 'rz-datetime-field', + ], + 'label' => $label, + 'years' => range((int) date('Y') - 10, (int) date('Y') + 10), + 'required' => false, + ]; + case AbstractField::INTEGER_T: + return [ + 'label' => $label, + 'required' => false, + 'constraints' => [ + new Type('integer'), + ], + ]; + case AbstractField::DECIMAL_T: + return [ + 'label' => $label, + 'required' => false, + 'constraints' => [ + new Type('double'), + ], + ]; + default: + return [ + 'label' => $label, + 'required' => false, + ]; + } + } +} diff --git a/lib/RoadizCoreBundle/src/Form/SettingTypeResolver.php b/lib/RoadizCoreBundle/src/Form/SettingTypeResolver.php new file mode 100644 index 00000000..4043b72e --- /dev/null +++ b/lib/RoadizCoreBundle/src/Form/SettingTypeResolver.php @@ -0,0 +1,51 @@ + TextType::class, + AbstractField::DATETIME_T => DateTimeType::class, + AbstractField::TEXT_T => TextareaType::class, + AbstractField::MARKDOWN_T => MarkdownType::class, + AbstractField::BOOLEAN_T => CheckboxType::class, + AbstractField::INTEGER_T => IntegerType::class, + AbstractField::DECIMAL_T => NumberType::class, + AbstractField::EMAIL_T => EmailType::class, + AbstractField::DOCUMENTS_T => FileType::class, + AbstractField::COLOUR_T => ColorType::class, + AbstractField::JSON_T => JsonType::class, + AbstractField::CSS_T => CssType::class, + AbstractField::YAML_T => YamlType::class, + AbstractField::ENUM_T => ChoiceType::class, + AbstractField::MULTIPLE_T => ChoiceType::class, + ]; + + /** + * @param Setting $setting + * @return class-string + */ + public function getSettingType(Setting $setting): string + { + return $this->typeToForm[$setting->getType()] ?? TextType::class; + } +} diff --git a/lib/RoadizCoreBundle/src/Form/TagTranslationDocumentType.php b/lib/RoadizCoreBundle/src/Form/TagTranslationDocumentType.php new file mode 100644 index 00000000..bc82c33f --- /dev/null +++ b/lib/RoadizCoreBundle/src/Form/TagTranslationDocumentType.php @@ -0,0 +1,101 @@ +managerRegistry = $managerRegistry; + } + + /** + * @param FormBuilderInterface $builder + * @param array $options + */ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder->addEventListener( + FormEvents::POST_SUBMIT, + [$this, 'onPostSubmit'] + ); + $builder->addModelTransformer(new TagTranslationDocumentsTransformer( + $this->managerRegistry->getManagerForClass(TagTranslationDocuments::class), + $options['tagTranslation'] + )); + } + + /** + * @param OptionsResolver $resolver + */ + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'required' => false, + 'allow_add' => true, + 'allow_delete' => true, + 'class' => TagTranslationDocuments::class, + ]); + + $resolver->setRequired('tagTranslation'); + $resolver->setAllowedTypes('tagTranslation', [TagTranslation::class]); + } + + /** + * {@inheritdoc} + */ + public function getBlockPrefix(): string + { + return 'documents'; + } + + /** + * @inheritDoc + */ + public function getParent(): ?string + { + return CollectionType::class; + } + + /** + * Delete existing document association. + * + * @param FormEvent $event + */ + public function onPostSubmit(FormEvent $event): void + { + if ($event->getForm()->getConfig()->getOption('tagTranslation') instanceof TagTranslation) { + $qb = $this->managerRegistry + ->getRepository(TagTranslationDocuments::class) + ->createQueryBuilder('ttd'); + $qb->delete() + ->andWhere($qb->expr()->eq('ttd.tagTranslation', ':tagTranslation')) + ->setParameter( + ':tagTranslation', + $event->getForm()->getConfig()->getOption('tagTranslation') + ); + $qb->getQuery()->execute(); + } + } +} diff --git a/lib/RoadizCoreBundle/src/Form/TagsType.php b/lib/RoadizCoreBundle/src/Form/TagsType.php new file mode 100644 index 00000000..2af4cb3c --- /dev/null +++ b/lib/RoadizCoreBundle/src/Form/TagsType.php @@ -0,0 +1,77 @@ +vars['attr']['placeholder'] = 'use.new_or_existing.tags_with_hierarchy'; + } + + /** + * Set every tags s default choices values. + * + * @param OptionsResolver $resolver + */ + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'allow_add' => true, + 'allow_delete' => true, + 'entry_type' => HiddenType::class, + 'label' => 'list.tags.to_link', + 'help' => 'use.new_or_existing.tags_with_hierarchy', + ]); + } + + /** + * {@inheritdoc} + * + * @param FormView $view + * @param FormInterface $form + * @param array $options + */ + public function finishView(FormView $view, FormInterface $form, array $options): void + { + parent::finishView($view, $form, $options); + + /* + * Inject data as plain documents entities + */ + $view->vars['data'] = $form->getData(); + } + + /** + * {@inheritdoc} + */ + public function getParent(): ?string + { + return CollectionType::class; + } + + /** + * {@inheritdoc} + */ + public function getBlockPrefix(): string + { + return 'tags'; + } +} diff --git a/lib/RoadizCoreBundle/src/Form/ThemesType.php b/lib/RoadizCoreBundle/src/Form/ThemesType.php new file mode 100644 index 00000000..de4becbb --- /dev/null +++ b/lib/RoadizCoreBundle/src/Form/ThemesType.php @@ -0,0 +1,52 @@ +setDefaults([ + 'choices' => [], + ]); + $resolver->setRequired('themes_config'); + $resolver->setAllowedTypes('themes_config', 'array'); + $resolver->setNormalizer('choices', function (Options $options, $value) { + $value = []; + foreach ($options['themes_config'] as $themeConfig) { + $class = $themeConfig['classname']; + $value[call_user_func([$class, 'getThemeName'])] = $class; + } + return $value; + }); + } + + /** + * {@inheritdoc} + */ + public function getParent(): ?string + { + return ChoiceType::class; + } + + /** + * {@inheritdoc} + */ + public function getBlockPrefix(): string + { + return 'classname'; + } +} diff --git a/lib/RoadizCoreBundle/src/Form/TranslationsType.php b/lib/RoadizCoreBundle/src/Form/TranslationsType.php new file mode 100644 index 00000000..5d444401 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Form/TranslationsType.php @@ -0,0 +1,65 @@ +managerRegistry = $managerRegistry; + } + + /** + * {@inheritdoc} + */ + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([]); + + /* + * Use normalizer to populate choices from ChoiceType + */ + $resolver->setNormalizer('choices', function (Options $options, $choices) { + $translations = $this->managerRegistry->getRepository(Translation::class)->findAll(); + + /** @var Translation $translation */ + foreach ($translations as $translation) { + $choices[$translation->getName()] = $translation->getId(); + } + + return $choices; + }); + } + + /** + * {@inheritdoc} + */ + public function getParent(): ?string + { + return ChoiceType::class; + } + /** + * {@inheritdoc} + */ + public function getBlockPrefix(): string + { + return 'translations'; + } +} diff --git a/lib/RoadizCoreBundle/src/Form/UrlAliasType.php b/lib/RoadizCoreBundle/src/Form/UrlAliasType.php new file mode 100644 index 00000000..70d9113a --- /dev/null +++ b/lib/RoadizCoreBundle/src/Form/UrlAliasType.php @@ -0,0 +1,58 @@ +managerRegistry = $managerRegistry; + } + + /** + * @inheritDoc + */ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder->add('alias', TextType::class, [ + 'label' => false, + 'attr' => [ + 'placeholder' => 'urlAlias', + ], + ]); + if ($options['with_translation']) { + $builder->add('translation', TranslationsType::class, [ + 'label' => false, + 'mapped' => false, + ]); + $builder->get('translation')->addModelTransformer(new TranslationTransformer( + $this->managerRegistry + )); + } + } + + /** + * @inheritDoc + */ + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefault('data_class', UrlAlias::class); + $resolver->setDefault('with_translation', false); + $resolver->setAllowedTypes('with_translation', ['bool']); + } +} diff --git a/lib/RoadizCoreBundle/src/Form/UserCollectionType.php b/lib/RoadizCoreBundle/src/Form/UserCollectionType.php new file mode 100644 index 00000000..ecef8dc5 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Form/UserCollectionType.php @@ -0,0 +1,64 @@ +managerRegistry = $managerRegistry; + } + + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder->addModelTransformer(new CollectionToArrayTransformer()); + } + + /** + * {@inheritdoc} + */ + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'multiple' => true, + 'choice_label' => function (?User $choice) { + return $choice ? $choice->getIdentifier() : ''; + }, + 'choice_value' => function (?User $choice) { + return $choice ? $choice->getId() : ''; + }, + ]); + + $resolver->setNormalizer('choices', function () { + return $this->managerRegistry->getRepository(User::class)->findAll(); + }); + } + + /** + * {@inheritdoc} + */ + public function getParent(): ?string + { + return ChoiceType::class; + } + + /** + * {@inheritdoc} + */ + public function getBlockPrefix(): string + { + return 'user_collection'; + } +} diff --git a/lib/RoadizCoreBundle/src/Form/UsersType.php b/lib/RoadizCoreBundle/src/Form/UsersType.php new file mode 100644 index 00000000..dcbcdcdb --- /dev/null +++ b/lib/RoadizCoreBundle/src/Form/UsersType.php @@ -0,0 +1,70 @@ +managerRegistry = $managerRegistry; + } + + /** + * {@inheritdoc} + */ + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'users' => new ArrayCollection(), + ]); + $resolver->setAllowedTypes('users', [Collection::class]); + + /* + * Use normalizer to populate choices from ChoiceType + */ + $resolver->setNormalizer('choices', function (Options $options, $choices) { + $users = $this->managerRegistry->getRepository(User::class)->findAll(); + + /** @var User $user */ + foreach ($users as $user) { + if (!$options['users']->contains($user)) { + $choices[$user->getUserName()] = $user->getId(); + } + } + return $choices; + }); + } + /** + * {@inheritdoc} + */ + public function getParent(): ?string + { + return ChoiceType::class; + } + /** + * {@inheritdoc} + */ + public function getBlockPrefix(): string + { + return 'users'; + } +} diff --git a/lib/RoadizCoreBundle/src/Form/WebhookType.php b/lib/RoadizCoreBundle/src/Form/WebhookType.php new file mode 100644 index 00000000..44eaf33e --- /dev/null +++ b/lib/RoadizCoreBundle/src/Form/WebhookType.php @@ -0,0 +1,70 @@ +webhookMessageTypes = $webhookMessageTypes; + } + + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder->add('messageType', ChoiceType::class, [ + 'required' => true, + 'label' => 'webhooks.messageType', + 'choices' => $this->webhookMessageTypes + ])->add('description', TextType::class, [ + 'required' => true, + 'label' => 'webhooks.description', + ])->add('uri', TextareaType::class, [ + 'required' => true, + 'label' => 'webhooks.uri', + ])->add('payload', YamlType::class, [ + 'required' => false, + 'label' => 'webhooks.payload', + ])->add('throttleSeconds', IntegerType::class, [ + 'required' => true, + 'label' => 'webhooks.throttleSeconds', + ])->add('automatic', CheckboxType::class, [ + 'required' => false, + 'label' => 'webhooks.automatic', + 'help' => 'webhooks.automatic.help', + ])->add('rootNode', NodesType::class, [ + 'required' => false, + 'label' => 'webhooks.rootNode', + 'help' => 'webhooks.rootNode.help', + 'multiple' => false + ]); + + $builder->get('payload')->addModelTransformer(new CallbackTransformer(function (?array $model) { + return $model ? Yaml::dump($model) : null; + }, function (?string $yaml) { + try { + return $yaml ? Yaml::parse($yaml) : null; + } catch (ParseException $e) { + throw new TransformationFailedException($e->getMessage(), 0, $e); + } + })); + } +} diff --git a/lib/RoadizCoreBundle/src/Form/WebhooksChoiceType.php b/lib/RoadizCoreBundle/src/Form/WebhooksChoiceType.php new file mode 100644 index 00000000..822dea13 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Form/WebhooksChoiceType.php @@ -0,0 +1,62 @@ +managerRegistry = $managerRegistry; + } + + public function buildForm(FormBuilderInterface $builder, array $options): void + { + parent::buildForm($builder, $options); + $builder->addModelTransformer(new CallbackTransformer(function (?Webhook $webhook) { + if (null === $webhook) { + return null; + } + return $webhook->getId(); + }, function (?string $id) { + if (null === $id) { + return null; + } + return $this->managerRegistry->getRepository(Webhook::class)->find($id); + })); + } + + public function configureOptions(OptionsResolver $resolver): void + { + parent::configureOptions($resolver); + + /** @var Webhook[] $webhooks */ + $webhooks = $this->managerRegistry->getRepository(Webhook::class)->findAll(); + $choices = []; + foreach ($webhooks as $webhook) { + $choices[(string) $webhook] = $webhook->getId(); + } + $resolver->setDefault('choices', $choices); + } +} diff --git a/lib/RoadizCoreBundle/src/Form/YamlType.php b/lib/RoadizCoreBundle/src/Form/YamlType.php new file mode 100644 index 00000000..668ddbed --- /dev/null +++ b/lib/RoadizCoreBundle/src/Form/YamlType.php @@ -0,0 +1,54 @@ +vars['attr']['class'] = 'yaml_textarea'; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'required' => false, + 'constraints' => [ + new ValidYaml() + ] + ]); + } +} diff --git a/lib/RoadizCoreBundle/src/Importer/AttributeImporter.php b/lib/RoadizCoreBundle/src/Importer/AttributeImporter.php new file mode 100644 index 00000000..a47127db --- /dev/null +++ b/lib/RoadizCoreBundle/src/Importer/AttributeImporter.php @@ -0,0 +1,48 @@ +serializer = $serializer; + } + + /** + * @inheritDoc + */ + public function supports(string $entityClass): bool + { + return $entityClass === Attribute::class || $entityClass === 'array<' . Attribute::class . '>'; + } + + /** + * @inheritDoc + */ + public function import(string $serializedData): bool + { + $this->serializer->deserialize( + $serializedData, + 'array<' . Attribute::class . '>', + 'json', + DeserializationContext::create() + ->setAttribute(TypedObjectConstructorInterface::PERSIST_NEW_OBJECTS, true) + ->setAttribute(TypedObjectConstructorInterface::FLUSH_NEW_OBJECTS, true) + ); + + return true; + } +} diff --git a/lib/RoadizCoreBundle/src/Importer/ChainImporter.php b/lib/RoadizCoreBundle/src/Importer/ChainImporter.php new file mode 100644 index 00000000..e975f723 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Importer/ChainImporter.php @@ -0,0 +1,65 @@ + $importers + */ + public function __construct(array $importers = []) + { + $this->importers = $importers; + } + + /** + * @param EntityImporterInterface $entityImporter + * + * @return ChainImporter + */ + public function addImporter(EntityImporterInterface $entityImporter): self + { + $this->importers[] = $entityImporter; + return $this; + } + + /** + * @inheritDoc + */ + public function supports(string $entityClass): bool + { + foreach ($this->importers as $importer) { + if ($importer instanceof EntityImporterInterface && $importer->supports($entityClass)) { + return true; + } + } + + return false; + } + + /** + * @inheritDoc + */ + public function import(string $serializedData): bool + { + throw new \RuntimeException('You cannot call import method on ChainImporter, but importWithType method'); + } + + + /** + * @inheritDoc + */ + public function importWithType(string $serializedData, string $entityClass): bool + { + foreach ($this->importers as $importer) { + if ($importer instanceof EntityImporterInterface && $importer->supports($entityClass)) { + return $importer->import($serializedData); + } + } + return false; + } +} diff --git a/lib/RoadizCoreBundle/src/Importer/EntityImporterInterface.php b/lib/RoadizCoreBundle/src/Importer/EntityImporterInterface.php new file mode 100644 index 00000000..cf176773 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Importer/EntityImporterInterface.php @@ -0,0 +1,22 @@ +serializer = $serializer; + } + + /** + * @inheritDoc + */ + public function supports(string $entityClass): bool + { + return $entityClass === Group::class; + } + + /** + * @inheritDoc + */ + public function import(string $serializedData): bool + { + $this->serializer->deserialize( + $serializedData, + 'array<' . Group::class . '>', + 'json', + DeserializationContext::create() + ->setAttribute(TypedObjectConstructorInterface::PERSIST_NEW_OBJECTS, true) + ->setAttribute(TypedObjectConstructorInterface::FLUSH_NEW_OBJECTS, true) + ); + return true; + } +} diff --git a/lib/RoadizCoreBundle/src/Importer/NodeTypesImporter.php b/lib/RoadizCoreBundle/src/Importer/NodeTypesImporter.php new file mode 100644 index 00000000..da1830a7 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Importer/NodeTypesImporter.php @@ -0,0 +1,57 @@ +serializer = $serializer; + $this->handlerFactory = $handlerFactory; + } + + /** + * @inheritDoc + */ + public function supports(string $entityClass): bool + { + return $entityClass === NodeType::class; + } + + /** + * @inheritDoc + */ + public function import(string $serializedData): bool + { + $nodeType = $this->serializer->deserialize( + $serializedData, + NodeType::class, + 'json', + DeserializationContext::create() + ->setAttribute(TypedObjectConstructorInterface::PERSIST_NEW_OBJECTS, true) + ->setAttribute(TypedObjectConstructorInterface::FLUSH_NEW_OBJECTS, true) + ); + + /** @var NodeTypeHandler $nodeTypeHandler */ + $nodeTypeHandler = $this->handlerFactory->getHandler($nodeType); + $nodeTypeHandler->updateSchema(); + + return true; + } +} diff --git a/lib/RoadizCoreBundle/src/Importer/RolesImporter.php b/lib/RoadizCoreBundle/src/Importer/RolesImporter.php new file mode 100644 index 00000000..ecdd5389 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Importer/RolesImporter.php @@ -0,0 +1,48 @@ +serializer = $serializer; + } + + /** + * @inheritDoc + */ + public function supports(string $entityClass): bool + { + return $entityClass === Role::class; + } + + /** + * @inheritDoc + */ + public function import(string $serializedData): bool + { + $this->serializer->deserialize( + $serializedData, + 'array<' . Role::class . '>', + 'json', + DeserializationContext::create() + ->setAttribute(TypedObjectConstructorInterface::PERSIST_NEW_OBJECTS, true) + ->setAttribute(TypedObjectConstructorInterface::FLUSH_NEW_OBJECTS, true) + ); + + return true; + } +} diff --git a/lib/RoadizCoreBundle/src/Importer/SettingsImporter.php b/lib/RoadizCoreBundle/src/Importer/SettingsImporter.php new file mode 100644 index 00000000..41ed59c0 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Importer/SettingsImporter.php @@ -0,0 +1,63 @@ +managerRegistry = $managerRegistry; + $this->serializer = $serializer; + } + + /** + * @inheritDoc + */ + public function supports(string $entityClass): bool + { + return $entityClass === Setting::class; + } + + /** + * @inheritDoc + */ + public function import(string $serializedData): bool + { + $settings = $this->serializer->deserialize( + $serializedData, + 'array<' . Setting::class . '>', + 'json', + DeserializationContext::create() + ->setAttribute(TypedObjectConstructorInterface::PERSIST_NEW_OBJECTS, true) + ->setAttribute(TypedObjectConstructorInterface::FLUSH_NEW_OBJECTS, true) + ); + + $manager = $this->managerRegistry->getManagerForClass(Setting::class); + if ($manager instanceof EntityManagerInterface) { + // Clear result cache + $cacheDriver = $manager->getConfiguration()->getResultCacheImpl(); + if ($cacheDriver instanceof CacheProvider) { + $cacheDriver->deleteAll(); + } + } + + return true; + } +} diff --git a/lib/RoadizCoreBundle/src/Importer/TagsImporter.php b/lib/RoadizCoreBundle/src/Importer/TagsImporter.php new file mode 100644 index 00000000..7f902f76 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Importer/TagsImporter.php @@ -0,0 +1,50 @@ +serializer = $serializer; + } + + /** + * @inheritDoc + */ + public function supports(string $entityClass): bool + { + return $entityClass === Tag::class; + } + + /** + * @inheritDoc + */ + public function import(string $serializedData): bool + { + $this->serializer->deserialize( + $serializedData, + Tag::class, + 'json', + DeserializationContext::create() + ->setAttribute(TypedObjectConstructorInterface::PERSIST_NEW_OBJECTS, true) + ->setAttribute(TypedObjectConstructorInterface::FLUSH_NEW_OBJECTS, true) + ->setAttribute(TagObjectConstructor::EXCEPTION_ON_EXISTING_TAG, true) + ); + + return true; + } +} diff --git a/lib/RoadizCoreBundle/src/ListManager/AbstractEntityListManager.php b/lib/RoadizCoreBundle/src/ListManager/AbstractEntityListManager.php new file mode 100644 index 00000000..ff2a3aac --- /dev/null +++ b/lib/RoadizCoreBundle/src/ListManager/AbstractEntityListManager.php @@ -0,0 +1,216 @@ +request = $request; + $this->displayNotPublishedNodes = false; + $this->displayAllNodesStatuses = false; + if (null !== $request) { + $this->queryArray = array_filter($request->query->all()); + } else { + $this->queryArray = []; + } + $this->itemPerPage = static::ITEM_PER_PAGE; + } + + public function setAllowRequestSorting(bool $allowRequestSorting) + { + $this->allowRequestSorting = $allowRequestSorting; + return $this; + } + + public function setAllowRequestSearching(bool $allowRequestSearching) + { + $this->allowRequestSearching = $allowRequestSearching; + return $this; + } + + /** + * @return bool + */ + public function isDisplayingNotPublishedNodes(): bool + { + return $this->displayNotPublishedNodes; + } + + /** + * @param bool $displayNotPublishedNodes + * @return EntityListManagerInterface + */ + public function setDisplayingNotPublishedNodes(bool $displayNotPublishedNodes) + { + $this->displayNotPublishedNodes = $displayNotPublishedNodes; + return $this; + } + + /** + * @return bool + */ + public function isDisplayingAllNodesStatuses(): bool + { + return $this->displayAllNodesStatuses; + } + + /** + * Switch repository to disable any security on Node status. To use ONLY in order to + * view deleted and archived nodes. + * + * @param bool $displayAllNodesStatuses + * @return EntityListManagerInterface + */ + public function setDisplayingAllNodesStatuses(bool $displayAllNodesStatuses) + { + $this->displayAllNodesStatuses = $displayAllNodesStatuses; + return $this; + } + + /** + * @inheritDoc + */ + public function setPage(int $page) + { + if ($page < 1) { + throw new \RuntimeException("Page cannot be lesser than 1.", 1); + } + $this->currentPage = (int) $page; + + return $this; + } + + /** + * @return int + */ + protected function getPage(): int + { + return $this->currentPage; + } + + /** + * @return EntityListManagerInterface + */ + public function enablePagination() + { + $this->pagination = true; + return $this; + } + + /** + * @inheritDoc + */ + public function disablePagination() + { + $this->setPage(1); + $this->pagination = false; + + return $this; + } + + /** + * @inheritDoc + */ + public function getAssignation(): array + { + $assign = [ + 'currentPage' => $this->getPage(), + 'pageCount' => $this->getPageCount(), + 'itemPerPage' => $this->getItemPerPage(), + 'itemCount' => $this->getItemCount(), + 'nextPageQuery' => null, + 'previousPageQuery' => null, + ]; + + if ($this->getPageCount() > 1) { + $assign['firstPageQuery'] = http_build_query(array_merge( + $this->getQueryString(), + ['page' => 1] + )); + $assign['lastPageQuery'] = http_build_query(array_merge( + $this->getQueryString(), + ['page' => $this->getPageCount()] + )); + } + + // compute next and prev page URL + if ($this->currentPage > 1) { + $previousQueryString = array_merge( + $this->getQueryString(), + ['page' => $this->getPage() - 1] + ); + $assign['previousPageQuery'] = http_build_query($previousQueryString); + $assign['previousQueryArray'] = $previousQueryString; + $assign['previousPage'] = $this->getPage() - 1; + } + // compute next and prev page URL + if ($this->getPage() < $this->getPageCount()) { + $nextQueryString = array_merge( + $this->getQueryString(), + ['page' => $this->getPage() + 1] + ); + $assign['nextPageQuery'] = http_build_query($nextQueryString); + $assign['nextQueryArray'] = $nextQueryString; + $assign['nextPage'] = $this->getPage() + 1; + } + + return $assign; + } + + protected function getQueryString(): array + { + return $this->queryArray ?? []; + } + + /** + * @return int + */ + protected function getItemPerPage(): int + { + return $this->itemPerPage; + } + + /** + * Configure a custom item count per page. + * + * @param int $itemPerPage + * + * @return EntityListManagerInterface + */ + public function setItemPerPage(int $itemPerPage) + { + if ($itemPerPage < 1) { + throw new \RuntimeException("Item count per page cannot be lesser than 1.", 1); + } + + $this->itemPerPage = (int) $itemPerPage; + + return $this; + } + + /** + * @return int + */ + public function getPageCount(): int + { + return (int) ceil($this->getItemCount() / $this->getItemPerPage()); + } +} diff --git a/lib/RoadizCoreBundle/src/ListManager/EntityListManager.php b/lib/RoadizCoreBundle/src/ListManager/EntityListManager.php new file mode 100644 index 00000000..bfd47aa5 --- /dev/null +++ b/lib/RoadizCoreBundle/src/ListManager/EntityListManager.php @@ -0,0 +1,278 @@ + + */ + protected string $entityName; + protected ObjectManager $entityManager; + protected ?Paginator $paginator = null; + protected ?array $orderingArray = null; + protected ?array $filteringArray = null; + protected ?string $searchPattern = null; + protected ?array $assignation = null; + protected ?TranslationInterface $translation = null; + + /** + * @param Request|null $request + * @param ObjectManager $entityManager + * @param class-string $entityName + * @param array $preFilters + * @param array $preOrdering + */ + public function __construct( + ?Request $request, + ObjectManager $entityManager, + string $entityName, + array $preFilters = [], + array $preOrdering = [] + ) { + parent::__construct($request); + $this->entityName = $entityName; + $this->entityManager = $entityManager; + $this->orderingArray = $preOrdering; + $this->filteringArray = $preFilters; + $this->assignation = []; + } + + /** + * @return TranslationInterface|null + */ + public function getTranslation(): ?TranslationInterface + { + return $this->translation; + } + + /** + * @param TranslationInterface|null $translation + * @return $this + */ + public function setTranslation(TranslationInterface $translation = null) + { + $this->translation = $translation; + + return $this; + } + + /** + * Handle request to find filter to apply to entity listing. + * + * @param bool $disabled Disable pagination and filtering over GET params + * @return void + */ + public function handle(bool $disabled = false) + { + // transform the key chroot in parent + if (array_key_exists('chroot', $this->filteringArray)) { + if ($this->filteringArray["chroot"] instanceof Node) { + /** @var NodeRepository $nodeRepo */ + $nodeRepo = $this->entityManager + ->getRepository(Node::class) + ->setDisplayingNotPublishedNodes($this->isDisplayingNotPublishedNodes()) + ->setDisplayingAllNodesStatuses($this->isDisplayingAllNodesStatuses()); + $ids = $nodeRepo->findAllOffspringIdByNode($this->filteringArray["chroot"]); // get all offspringId + if (array_key_exists('parent', $this->filteringArray)) { + // test if parent key exist + if (is_array($this->filteringArray["parent"])) { + // test if multiple parent id + if ( + count(array_intersect($this->filteringArray["parent"], $ids)) + != count($this->filteringArray["parent"]) + ) { + // test if all parent are in the chroot + $this->filteringArray["parent"] = -1; // -1 for make the search return [] + } + } else { + if ($this->filteringArray["parent"] instanceof Node) { + // make transform all id in int + $parent = $this->filteringArray["parent"]->getId(); + } else { + $parent = (int) $this->filteringArray["parent"]; + } + if (!in_array($parent, $ids, true)) { + $this->filteringArray["parent"] = -1; + } + } + } else { + $this->filteringArray["parent"] = $ids; + } + } + unset($this->filteringArray["chroot"]); // remove placeholder + } + + if (false === $disabled && null !== $this->request) { + if ( + $this->allowRequestSorting && + $this->request->query->get('field') && + $this->request->query->get('ordering') + ) { + $this->orderingArray = [ + $this->request->query->get('field') => $this->request->query->get('ordering') + ]; + $this->queryArray['field'] = $this->request->query->get('field'); + $this->queryArray['ordering'] = $this->request->query->get('ordering'); + } + + if ($this->allowRequestSearching && $this->request->query->get('search') != "") { + $this->searchPattern = $this->request->query->get('search'); + $this->queryArray['search'] = $this->request->query->get('search'); + } + + if ( + $this->request->query->has('item_per_page') && + $this->request->query->get('item_per_page') > 0 + ) { + $this->setItemPerPage((int) $this->request->query->get('item_per_page')); + } + + if ( + $this->request->query->has('page') && + $this->request->query->get('page') > 1 + ) { + $this->setPage((int) $this->request->query->get('page')); + } else { + $this->setPage(1); + } + } else { + /* + * Disable pagination and paginator + */ + $this->disablePagination(); + } + + $this->createPaginator(); + + if ( + $this->allowRequestSearching && + false === $disabled && + null !== $this->request && + $this->request->query->get('search') != "" + ) { + $this->paginator->setSearchPattern($this->request->query->get('search')); + } + } + + protected function createPaginator(): void + { + if ( + $this->entityName === Node::class || + $this->entityName === 'RZ\Roadiz\CoreBundle\Entity\Node' || + $this->entityName === '\RZ\Roadiz\CoreBundle\Entity\Node' || + $this->entityName === "Node" + ) { + $this->paginator = new NodePaginator( + $this->entityManager, + $this->entityName, + $this->itemPerPage, + $this->filteringArray + ); + $this->paginator->setTranslation($this->translation); + } elseif ( + $this->entityName == NodesSources::class || + $this->entityName == 'RZ\Roadiz\CoreBundle\Entity\NodesSources' || + $this->entityName == '\RZ\Roadiz\CoreBundle\Entity\NodesSources' || + $this->entityName == "NodesSources" || + str_contains($this->entityName, NodeType::getGeneratedEntitiesNamespace()) + ) { + $this->paginator = new NodesSourcesPaginator( + $this->entityManager, + $this->entityName, + $this->itemPerPage, + $this->filteringArray + ); + } else { + $this->paginator = new Paginator( + $this->entityManager, + $this->entityName, + $this->itemPerPage, + $this->filteringArray + ); + } + + $this->paginator->setDisplayingNotPublishedNodes($this->isDisplayingNotPublishedNodes()); + $this->paginator->setDisplayingAllNodesStatuses($this->isDisplayingAllNodesStatuses()); + } + + /** + * @return array + */ + public function getAssignation(): array + { + return array_merge(parent::getAssignation(), [ + 'search' => $this->searchPattern, + ]); + } + + /** + * @return int + */ + public function getItemCount(): int + { + if ( + $this->pagination === true && + null !== $this->paginator + ) { + return $this->paginator->getTotalCount(); + } + + return 0; + } + + /** + * @return int + */ + public function getPageCount(): int + { + if ( + $this->pagination === true && + null !== $this->paginator + ) { + return $this->paginator->getPageCount(); + } + + return 1; + } + + /** + * Return filtered entities. + * + * @return array|DoctrinePaginator + */ + public function getEntities(): array|DoctrinePaginator + { + if ($this->pagination === true && null !== $this->paginator) { + $this->paginator->setItemsPerPage($this->getItemPerPage()); + return $this->paginator->findByAtPage($this->orderingArray, $this->currentPage); + } else { + $repository = $this->entityManager->getRepository($this->entityName); + if ($repository instanceof StatusAwareRepository) { + $repository->setDisplayingNotPublishedNodes($this->isDisplayingNotPublishedNodes()); + $repository->setDisplayingAllNodesStatuses($this->isDisplayingAllNodesStatuses()); + } + return $repository->findBy( + $this->filteringArray, + $this->orderingArray, + $this->itemPerPage + ); + } + } +} diff --git a/lib/RoadizCoreBundle/src/ListManager/EntityListManagerInterface.php b/lib/RoadizCoreBundle/src/ListManager/EntityListManagerInterface.php new file mode 100644 index 00000000..4b5c3551 --- /dev/null +++ b/lib/RoadizCoreBundle/src/ListManager/EntityListManagerInterface.php @@ -0,0 +1,117 @@ +translation; + } + + /** + * @param TranslationInterface|null $translation + * + * @return $this + */ + public function setTranslation(TranslationInterface $translation = null) + { + $this->translation = $translation; + return $this; + } + + /** + * Return entities filtered for current page. + * + * @param array $order + * @param integer $page + * + * @return array + */ + public function findByAtPage(array $order = [], $page = 1) + { + if (null !== $this->searchPattern) { + return $this->searchByAtPage($order, $page); + } else { + $repository = $this->getRepository(); + if ($repository instanceof NodeRepository) { + return $repository->findBy( + $this->criteria, + $order, + $this->getItemsPerPage(), + $this->getItemsPerPage() * ($page - 1), + $this->getTranslation() + ); + } + return $repository->findBy( + $this->criteria, + $order, + $this->getItemsPerPage(), + $this->getItemsPerPage() * ($page - 1) + ); + } + } + + /** + * @return int + */ + public function getTotalCount(): int + { + if (null === $this->totalCount) { + if (null !== $this->searchPattern) { + $this->totalCount = $this->getRepository() + ->countSearchBy($this->searchPattern, $this->criteria); + } else { + $repository = $this->getRepository(); + if ($repository instanceof NodeRepository) { + $this->totalCount = $repository->countBy( + $this->criteria, + $this->getTranslation() + ); + } + $this->totalCount = $repository->countBy( + $this->criteria + ); + } + } + + return $this->totalCount; + } +} diff --git a/lib/RoadizCoreBundle/src/ListManager/NodesSourcesPaginator.php b/lib/RoadizCoreBundle/src/ListManager/NodesSourcesPaginator.php new file mode 100644 index 00000000..288d0611 --- /dev/null +++ b/lib/RoadizCoreBundle/src/ListManager/NodesSourcesPaginator.php @@ -0,0 +1,51 @@ +totalCount) { + if (null !== $this->searchPattern) { + $this->totalCount = $this->getRepository()->countSearchBy($this->searchPattern, $this->criteria); + } else { + $this->totalCount = $this->getRepository()->countBy($this->criteria); + } + } + + return $this->totalCount; + } + + /** + * Return entities filtered for current page. + * + * @param array $order + * @param integer $page + * + * @return array + */ + public function findByAtPage(array $order = [], int $page = 1) + { + if (null !== $this->searchPattern) { + return $this->searchByAtPage($order, $page); + } else { + return $this->getRepository()->findBy( + $this->criteria, + $order, + $this->getItemsPerPage(), + $this->getItemsPerPage() * ($page - 1) + ); + } + } +} diff --git a/lib/RoadizCoreBundle/src/ListManager/Paginator.php b/lib/RoadizCoreBundle/src/ListManager/Paginator.php new file mode 100644 index 00000000..cf218b8d --- /dev/null +++ b/lib/RoadizCoreBundle/src/ListManager/Paginator.php @@ -0,0 +1,277 @@ +em = $em; + $this->entityName = $entityName; + $this->itemsPerPage = $itemPerPages; + $this->criteria = $criteria; + $this->displayNotPublishedNodes = false; + $this->displayAllNodesStatuses = false; + + if (empty($this->entityName)) { + throw new \RuntimeException("Entity name could not be empty", 1); + } + if ($this->itemsPerPage < 1) { + throw new \RuntimeException("Items par page could not be lesser than 1.", 1); + } + } + + /** + * @return bool + */ + public function isDisplayingNotPublishedNodes(): bool + { + return $this->displayNotPublishedNodes; + } + + /** + * @param bool $displayNonPublishedNodes + * @return Paginator + */ + public function setDisplayingNotPublishedNodes(bool $displayNonPublishedNodes) + { + $this->displayNotPublishedNodes = $displayNonPublishedNodes; + return $this; + } + + /** + * @return bool + */ + public function isDisplayingAllNodesStatuses(): bool + { + return $this->displayAllNodesStatuses; + } + + /** + * Switch repository to disable any security on Node status. To use ONLY in order to + * view deleted and archived nodes. + * + * @param bool $displayAllNodesStatuses + * @return $this + */ + public function setDisplayingAllNodesStatuses(bool $displayAllNodesStatuses) + { + $this->displayAllNodesStatuses = $displayAllNodesStatuses; + return $this; + } + + /** + * @return string + */ + public function getSearchPattern() + { + return $this->searchPattern; + } + + /** + * @param string $searchPattern + * + * @return $this + */ + public function setSearchPattern($searchPattern) + { + $this->searchPattern = $searchPattern; + + return $this; + } + + /** + * Return total entities count for given criteria. + * + * @return int + */ + public function getTotalCount(): int + { + if (null === $this->totalCount) { + $repository = $this->getRepository(); + if ($repository instanceof EntityRepository) { + if (null !== $this->searchPattern) { + $this->totalCount = $repository->countSearchBy($this->searchPattern, $this->criteria); + } else { + $this->totalCount = $repository->countBy($this->criteria); + } + } else { + if (null !== $this->searchPattern) { + /* + * Use QueryBuilder for non-roadiz entities + */ + $qb = $this->getSearchQueryBuilder(); + $qb->select($qb->expr()->countDistinct('o')); + try { + return (int)$qb->getQuery()->getSingleScalarResult(); + } catch (NoResultException | NonUniqueResultException $e) { + return 0; + } + } + $this->totalCount = $repository->count($this->criteria); + } + } + + return $this->totalCount; + } + + /** + * Return page count according to criteria. + * + * @return int + */ + public function getPageCount(): int + { + return (int) ceil($this->getTotalCount() / $this->getItemsPerPage()); + } + + /** + * Return entities filtered for current page. + * + * @param array $order + * @param int $page + * + * @return array|\Doctrine\ORM\Tools\Pagination\Paginator + */ + public function findByAtPage(array $order = [], int $page = 1) + { + if (null !== $this->searchPattern) { + return $this->searchByAtPage($order, $page); + } else { + return $this->getRepository() + ->findBy( + $this->criteria, + $order, + $this->getItemsPerPage(), + $this->getItemsPerPage() * ($page - 1) + ); + } + } + + /** + * Use a search query to paginate instead of a findBy. + * + * @param array $order + * @param int $page + * + * @return array + */ + public function searchByAtPage(array $order = [], int $page = 1) + { + $repository = $this->getRepository(); + if ($repository instanceof EntityRepository) { + return $repository->searchBy( + $this->searchPattern, + $this->criteria, + $order, + $this->getItemsPerPage(), + $this->getItemsPerPage() * ($page - 1) + ); + } + + /* + * Use QueryBuilder for non-roadiz entities + */ + $qb = $this->getSearchQueryBuilder(); + $qb->setMaxResults($this->getItemsPerPage()) + ->setFirstResult($this->getItemsPerPage() * ($page - 1)); + + foreach ($order as $key => $value) { + $qb->addOrderBy('o.' . $key, $value); + } + + return $qb->getQuery()->getResult(); + } + + /** + * @param int $itemsPerPage + * + * @return $this + */ + public function setItemsPerPage(int $itemsPerPage) + { + $this->itemsPerPage = $itemsPerPage; + + return $this; + } + /** + * @return int + */ + public function getItemsPerPage(): int + { + return $this->itemsPerPage; + } + + protected function getSearchQueryBuilder(): QueryBuilder + { + $searchableFields = $this->getSearchableFields(); + if (count($searchableFields) === 0) { + throw new \RuntimeException('Entity has no searchable field.'); + } + $qb = $this->getRepository()->createQueryBuilder('o'); + $orX = []; + foreach ($this->getSearchableFields() as $field) { + $orX[] = $qb->expr()->like('o.' . $field, $qb->expr()->literal('%' . $this->searchPattern . '%')); + } + $qb->andWhere($qb->expr()->orX(...$orX)); + return $qb; + } + + protected function getSearchableFields(): array + { + return array_filter( + ['name', 'title', 'slug'], + function (string $fieldName) { + return $this->em->getClassMetadata($this->entityName)->hasField($fieldName); + } + ); + } + + /** + * @return \Doctrine\ORM\EntityRepository|EntityRepository|StatusAwareRepository + */ + protected function getRepository() + { + $repository = $this->em->getRepository($this->entityName); + if ($repository instanceof StatusAwareRepository) { + $repository->setDisplayingNotPublishedNodes($this->isDisplayingNotPublishedNodes()); + $repository->setDisplayingAllNodesStatuses($this->isDisplayingAllNodesStatuses()); + } + return $repository; + } +} diff --git a/lib/RoadizCoreBundle/src/ListManager/QueryBuilderListManager.php b/lib/RoadizCoreBundle/src/ListManager/QueryBuilderListManager.php new file mode 100644 index 00000000..ca55b02d --- /dev/null +++ b/lib/RoadizCoreBundle/src/ListManager/QueryBuilderListManager.php @@ -0,0 +1,148 @@ +queryBuilder = $queryBuilder; + $this->identifier = $identifier; + $this->debug = $debug; + } + + /** + * @param string $search + */ + protected function handleSearchParam(string $search): void + { + // Implement your custom logic + } + + public function handle(bool $disabled = false) + { + if (false === $disabled && null !== $this->request) { + if ( + $this->allowRequestSorting && + $this->request->query->get('field') && + $this->request->query->get('ordering') + ) { + $this->queryBuilder->addOrderBy( + sprintf('%s.%s', $this->identifier, $this->request->query->get('field')), + $this->request->query->get('ordering') + ); + $this->queryArray['field'] = $this->request->query->get('field'); + $this->queryArray['ordering'] = $this->request->query->get('ordering'); + } + + if ($this->allowRequestSearching && $this->request->query->get('search') != "") { + $this->handleSearchParam($this->request->query->get('search')); + $this->queryArray['search'] = $this->request->query->get('search'); + } + + if ( + $this->request->query->has('item_per_page') && + $this->request->query->get('item_per_page') > 0 + ) { + $this->setItemPerPage((int) $this->request->query->get('item_per_page')); + } + + if ( + $this->request->query->has('page') && + $this->request->query->get('page') > 1 + ) { + $this->setPage((int) $this->request->query->get('page')); + } else { + $this->setPage(1); + } + } else { + /* + * Disable pagination and paginator + */ + $this->disablePagination(); + } + } + + /** + * @return Paginator + */ + protected function getPaginator(): Paginator + { + if (null === $this->paginator) { + $this->paginator = new Paginator($this->queryBuilder); + } + return $this->paginator; + } + + /** + * @inheritDoc + */ + public function setPage(int $page): self + { + parent::setPage($page); + $this->queryBuilder->setFirstResult($this->getItemPerPage() * ($page - 1)); + return $this; + } + + /** + * @inheritDoc + */ + public function setItemPerPage(int $itemPerPage): self + { + parent::setItemPerPage($itemPerPage); + $this->queryBuilder->setMaxResults((int) $itemPerPage); + return $this; + } + + + /** + * @inheritDoc + */ + public function getItemCount(): int + { + return $this->getPaginator()->count(); + } + + /** + * @inheritDoc + */ + public function getEntities(): Paginator + { + return $this->getPaginator(); + } + + /** + * @return array + */ + public function getAssignation(): array + { + if ($this->debug) { + return array_merge(parent::getAssignation(), [ + 'dql_query' => $this->queryBuilder->getDQL() + ]); + } + return parent::getAssignation(); + } +} diff --git a/lib/RoadizCoreBundle/src/ListManager/TagListManager.php b/lib/RoadizCoreBundle/src/ListManager/TagListManager.php new file mode 100644 index 00000000..4be28eb7 --- /dev/null +++ b/lib/RoadizCoreBundle/src/ListManager/TagListManager.php @@ -0,0 +1,50 @@ +searchPattern != '') { + return $this->entityManager + ->getRepository(TagTranslation::class) + ->searchBy($this->searchPattern, $this->filteringArray, $this->orderingArray); + } else { + return $this->paginator->findByAtPage($this->filteringArray, $this->currentPage); + } + } catch (\Exception $e) { + return []; + } + } +} diff --git a/lib/RoadizCoreBundle/src/Logger/DoctrineHandler.php b/lib/RoadizCoreBundle/src/Logger/DoctrineHandler.php new file mode 100644 index 00000000..330dea02 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Logger/DoctrineHandler.php @@ -0,0 +1,143 @@ +tokenStorage = $tokenStorage; + $this->requestStack = $requestStack; + $this->managerRegistry = $managerRegistry; + + parent::__construct($level, $bubble); + } + + /** + * @return TokenStorageInterface + */ + public function getTokenStorage(): TokenStorageInterface + { + return $this->tokenStorage; + } + /** + * @param TokenStorageInterface $tokenStorage + * + * @return $this + */ + public function setTokenStorage(TokenStorageInterface $tokenStorage): DoctrineHandler + { + $this->tokenStorage = $tokenStorage; + return $this; + } + + /** + * @param array $record + */ + public function write(array $record): void + { + try { + $manager = $this->managerRegistry->getManagerForClass(Log::class); + if (null === $manager || !$manager->isOpen()) { + return; + } + + $log = new Log( + $record['level'], + $record['message'] + ); + + $log->setChannel((string) $record['channel']); + $data = $record['extra']; + if (isset($record['context']['exception']) && $record['context']['exception'] instanceof \Exception) { + $data = array_merge( + $data, + [ + get_class($record['context']['exception']) => $record['context']['exception']->getMessage() + ] + ); + } + if (isset($record['context']['request'])) { + $data = array_merge( + $data, + $record['context']['request'] + ); + } + if (isset($record['context']['username'])) { + $data = array_merge( + $data, + ['username' => $record['context']['username']] + ); + } + $log->setAdditionalData($data); + + /* + * Use available securityAuthorizationChecker to provide a valid user + */ + if ( + null !== $this->getTokenStorage() && + null !== $token = $this->getTokenStorage()->getToken() + ) { + $user = $token->getUser(); + if ($user instanceof UserInterface) { + if ($user instanceof User) { + $log->setUser($user); + } else { + $log->setUsername($user->getUsername()); + } + } else { + $log->setUsername($token->getUsername()); + } + } + + /* + * Add client IP to log if it’s an HTTP request + */ + if (null !== $this->requestStack->getMainRequest()) { + $log->setClientIp($this->requestStack->getMainRequest()->getClientIp()); + } + + /* + * Add a related node-source entity + */ + if ( + isset($record['context']['source']) && + $record['context']['source'] instanceof NodesSources + ) { + $log->setNodeSource($record['context']['source']); + } + + $manager->persist($log); + $manager->flush(); + } catch (\Exception $e) { + /* + * Need to prevent SQL errors over throwing + * if PDO has faulted + */ + } + } +} diff --git a/lib/RoadizCoreBundle/src/Mailer/ContactFormManager.php b/lib/RoadizCoreBundle/src/Mailer/ContactFormManager.php new file mode 100644 index 00000000..dbcea3af --- /dev/null +++ b/lib/RoadizCoreBundle/src/Mailer/ContactFormManager.php @@ -0,0 +1,729 @@ +formFactory = $formFactory; + $this->formErrorSerializer = $formErrorSerializer; + $this->options = [ + 'attr' => [ + 'id' => 'contactForm', + ], + ]; + + $this->successMessage = 'form.successfully.sent'; + $this->failMessage = 'form.has.errors'; + $this->emailTemplate = '@RoadizCore/email/forms/contactForm.html.twig'; + $this->emailPlainTextTemplate = '@RoadizCore/email/forms/contactForm.txt.twig'; + + $this->setSubject($this->translator->trans( + 'new.contact.form.%site%', + ['%site%' => $this->settingsBag->get('site_name')] + )); + + $this->setEmailTitle($this->translator->trans( + 'new.contact.form.%site%', + ['%site%' => $this->settingsBag->get('site_name')] + )); + $this->recaptchaPrivateKey = $recaptchaPrivateKey; + $this->recaptchaPublicKey = $recaptchaPublicKey; + } + + /** + * @return string + */ + public function getFormName(): string + { + return $this->formName; + } + + /** + * Use this method BEFORE withDefaultFields() + * + * @param string $formName + * @return ContactFormManager + */ + public function setFormName(string $formName): ContactFormManager + { + $this->formName = $formName; + return $this; + } + + /** + * Use this method BEFORE withDefaultFields() + * + * @return $this + */ + public function disableCsrfProtection() + { + $this->options['csrf_protection'] = false; + return $this; + } + + /** + * @return FormInterface + */ + public function getForm(): FormInterface + { + return $this->form; + } + + /** + * Using the strict mode requires the "egulias/email-validator" library. + * + * Use this method BEFORE withDefaultFields() + * + * @param bool $emailStrictMode + * @see https://symfony.com/doc/4.4/reference/constraints/Email.html#strict + * @return $this + */ + public function setEmailStrictMode(bool $emailStrictMode = true) + { + $this->emailStrictMode = $emailStrictMode; + + return $this; + } + /** + * @return bool + */ + public function isEmailStrictMode(): bool + { + return $this->emailStrictMode; + } + + /** + * Adds email, name and message fields with their constraints. + * + * @param bool $useHoneypot + * @return ContactFormManager $this + */ + public function withDefaultFields(bool $useHoneypot = true) + { + $this->getFormBuilder()->add('email', EmailType::class, [ + 'label' => 'your.email', + 'constraints' => [ + new NotNull(), + new NotBlank(), + new Email([ + 'message' => 'email.not.valid', + 'mode' => $this->isEmailStrictMode() ? + Email::VALIDATION_MODE_STRICT : + Email::VALIDATION_MODE_LOOSE + ]), + ], + ]) + ->add('name', TextType::class, [ + 'label' => 'your.name', + 'constraints' => [ + new NotNull(), + new NotBlank(), + ], + ]) + ->add('message', TextareaType::class, [ + 'label' => 'your.message', + 'constraints' => [ + new NotNull(), + new NotBlank(), + ], + ]) + ; + + if ($useHoneypot) { + $this->withHoneypot(); + } + + return $this; + } + + /** + * Use this method AFTER withDefaultFields() + * + * @param string $honeypotName + * @return $this + */ + public function withHoneypot(string $honeypotName = 'eml') + { + $this->getFormBuilder()->add($honeypotName, HoneypotType::class); + return $this; + } + + /** + * Use this method AFTER withDefaultFields() + * + * @param string $consentDescription + * @return $this + */ + public function withUserConsent(string $consentDescription = 'contact_form.user_consent') + { + $this->getFormBuilder()->add('consent', CheckboxType::class, [ + 'label' => $consentDescription, + 'required' => true, + 'constraints' => [ + new NotBlank([ + 'message' => 'contact_form.must_consent_to_send', + ]), + ], + ]); + return $this; + } + + /** + * @return FormBuilderInterface + */ + public function getFormBuilder(): FormBuilderInterface + { + if (null === $this->formBuilder) { + $this->formBuilder = $this->formFactory + ->createNamedBuilder($this->getFormName(), FormType::class, null, $this->options) + ->setMethod($this->method); + } + return $this->formBuilder; + } + + /** + * Add a Google recaptcha to your contact form. + * + * Make sure you’ve added recaptcha form template and filled + * recaptcha_public_key and recaptcha_private_key settings. + * + * + * + * {% block recaptcha_widget -%} + *
+ * {%- endblock recaptcha_widget %} + * + * If you are using API REST POST form, use 'g-recaptcha-response' name + * to enable Validator to get challenge value. + * + * @return ContactFormManager + */ + public function withGoogleRecaptcha( + string $name = 'recaptcha', + string $validatorFieldName = Recaptcha::FORM_NAME + ) { + if ( + !empty($this->recaptchaPublicKey) && + !empty($this->recaptchaPrivateKey) + ) { + $this->getFormBuilder()->add($name, RecaptchaType::class, [ + 'label' => false, + 'configs' => [ + 'publicKey' => $this->recaptchaPublicKey, + ], + 'constraints' => [ + new Recaptcha([ + 'fieldName' => $validatorFieldName, + 'privateKey' => $this->recaptchaPrivateKey, + ]), + ], + ]); + } + + return $this; + } + + /** + * Handle custom form validation and send it as an email. + * + * @param callable|null $onValid + * @return Response|null + * @throws \Exception + */ + public function handle(?callable $onValid = null): ?Response + { + $request = $this->requestStack->getMainRequest(); + if (null === $request) { + throw new \RuntimeException('Main request is null'); + } + $this->form = $this->getFormBuilder()->getForm(); + $this->form->handleRequest($request); + $returnJson = $request->isXmlHttpRequest() || + $request->getRequestFormat() === 'json' || + (count($request->getAcceptableContentTypes()) === 1 && $request->getAcceptableContentTypes()[0] === 'application/json') || + ($request->attributes->has('_format') && $request->attributes->get('_format') === 'json'); + + if ($this->form->isSubmitted()) { + if ($this->form->isValid()) { + try { + if (null !== $onValid) { + $onValid($this->form); + } + + $this->handleFiles(); + $this->handleFormData($this->form); + $this->send(); + if ($returnJson) { + return new JsonResponse([], Response::HTTP_ACCEPTED); + } else { + if ($request->hasPreviousSession()) { + /** @var Session $session */ + $session = $request->getSession(); + $session->getFlashBag() + ->add('confirm', $this->translator->trans($this->successMessage)); + } + + $this->redirectUrl = $this->redirectUrl !== null ? $this->redirectUrl : $request->getUri(); + return new RedirectResponse($this->redirectUrl); + } + } catch (BadFormRequestException $e) { + if (null !== $e->getFieldErrored() && $this->form->has($e->getFieldErrored())) { + $this->form->get($e->getFieldErrored())->addError(new FormError($e->getMessage())); + } else { + $this->form->addError(new FormError($e->getMessage())); + } + } catch (TransportExceptionInterface $exception) { + $this->form->addError(new FormError('Contact form could not be sent.')); + } + } + if ($returnJson) { + /* + * If form has errors during AJAX + * request we sent them. + */ + $errorPerForm = $this->formErrorSerializer->getErrorsAsArray($this->form); + $responseArray = [ + 'status' => Response::HTTP_BAD_REQUEST, + 'message' => $this->translator->trans($this->failMessage), + 'errors' => (string) $this->form->getErrors(), + 'errorsPerForm' => $errorPerForm, + ]; + /* + * BC: Still return 200 if form is not valid for Ajax forms + */ + return new JsonResponse( + $responseArray, + $this->useRealResponseCode() ? Response::HTTP_BAD_REQUEST : Response::HTTP_OK + ); + } + } + return null; + } + + protected function handleFiles(): void + { + $this->uploadedFiles = []; + $request = $this->requestStack->getMainRequest(); + if (null === $request) { + return; + } + /* + * Files values + */ + foreach ($request->files as $files) { + /** + * @var string $name + * @var UploadedFile|array $uploadedFile + */ + foreach ($files as $name => $uploadedFile) { + if (null !== $uploadedFile) { + if (is_array($uploadedFile)) { + /** + * @var string $singleName + * @var UploadedFile|array $singleUploadedFile + */ + foreach ($uploadedFile as $singleName => $singleUploadedFile) { + if (is_array($singleUploadedFile)) { + /** + * @var string $singleName2 + * @var UploadedFile $singleUploadedFile2 + */ + foreach ($singleUploadedFile as $singleName2 => $singleUploadedFile2) { + $this->addUploadedFile($singleName2, $singleUploadedFile2); + } + } else { + $this->addUploadedFile($singleName, $singleUploadedFile); + } + } + } else { + $this->addUploadedFile($name, $uploadedFile); + } + } + } + } + } + + /** + * @param string $name + * @param UploadedFile $uploadedFile + * @return $this + * @throws BadFormRequestException + */ + protected function addUploadedFile(string $name, UploadedFile $uploadedFile): ContactFormManager + { + if ( + !$uploadedFile->isValid() || + !in_array($uploadedFile->getMimeType(), $this->allowedMimeTypes) || + $uploadedFile->getSize() > $this->maxFileSize + ) { + throw new BadFormRequestException( + $this->translator->trans('file.not.accepted'), + Response::HTTP_FORBIDDEN, + 'danger', + $name + ); + } else { + $this->uploadedFiles[$name] = $uploadedFile; + } + + return $this; + } + + /** + * @param array $formData + * @return string|null + */ + protected function findEmailData(array $formData): ?string + { + foreach ($formData as $key => $value) { + if ( + (new UnicodeString($key))->containsAny('email') && + is_string($value) && + filter_var($value, FILTER_VALIDATE_EMAIL) + ) { + return $value; + } elseif (is_array($value) && null !== $email = $this->findEmailData($value)) { + return $email; + } + } + return null; + } + + /** + * @param FormInterface $form + * + * @throws \Exception + */ + protected function handleFormData(FormInterface $form): void + { + $formData = $form->getData(); + $fields = $this->flattenFormData($form, []); + + /* + * Sender email + */ + $emailData = $this->findEmailData($formData); + if (!empty($emailData)) { + $this->setSender($emailData); + } + + /** + * @var string $key + * @var UploadedFile $uploadedFile + */ + foreach ($this->uploadedFiles as $key => $uploadedFile) { + $fields[] = [ + 'name' => strip_tags((string) $key), + 'value' => (strip_tags($uploadedFile->getClientOriginalName()) . + ' [' . $uploadedFile->guessExtension() . ']'), + ]; + } + /* + * Date + */ + $fields[] = [ + 'name' => $this->translator->trans('date'), + 'value' => (new \DateTime())->format('Y-m-d H:i:s'), + ]; + /* + * IP + */ + $fields[] = [ + 'name' => $this->translator->trans('ip.address'), + 'value' => $this->requestStack->getMainRequest()->getClientIp(), + ]; + + $this->assignation = [ + 'mailContact' => $this->settingsBag->get('email_sender'), + 'emailType' => $this->getEmailType(), + 'title' => $this->getEmailTitle(), + 'email' => $this->getSender(), + 'fields' => $fields, + ]; + } + + protected function isFieldPrivate(FormInterface $form): bool + { + $key = $form->getName(); + $privateFieldNames = [ + Recaptcha::FORM_NAME, + 'recaptcha' + ]; + return + is_string($key) && + (substr($key, 0, 1) === '_' || \in_array($key, $privateFieldNames)) + ; + } + + /** + * @param FormInterface $form + * @param array $fields + * @return array + */ + protected function flattenFormData(FormInterface $form, array $fields): array + { + /** @var FormInterface $formItem */ + foreach ($form as $formItem) { + $key = $formItem->getName(); + $value = $formItem->getData(); + $displayName = $formItem->getConfig()->getOption("label") ?? + (is_numeric($key) ? null : strip_tags(trim((string) $key))); + + if ($this->isFieldPrivate($formItem) || $value instanceof UploadedFile) { + continue; + } elseif ($formItem->count() > 0) { + if (!empty($displayName)) { + $fields[] = [ + 'name' => $displayName, + 'value' => null, + ]; + } + $fields = $this->flattenFormData($formItem, $fields); + } elseif (!empty($value)) { + if ($value instanceof \DateTimeInterface) { + $displayValue = $value->format('Y-m-d H:i:s'); + } else { + $displayValue = strip_tags(trim((string) $value)); + } + + $fields[] = [ + 'name' => $displayName, + 'value' => $displayValue, + ]; + } + } + + return $fields; + } + + + /** + * Send contact form data by email. + * + * @return void + * @throws \RuntimeException + */ + public function send(): void + { + if (empty($this->assignation)) { + throw new \RuntimeException("Can’t send a contact form without data."); + } + + $this->message = $this->createMessage(); + + /* + * As this is a contact form + * email receiver is website owner or custom. + * + * So you must return error email to receiver instead + * of sender (who is your visitor). + */ + $this->message->to(...$this->getReceiver()); + $this->message->returnPath($this->getReceiverEmail()); + + /** @var UploadedFile $uploadedFile */ + foreach ($this->uploadedFiles as $uploadedFile) { + $this->message->attachFromPath($uploadedFile->getRealPath(), $uploadedFile->getClientOriginalName()); + } + + // Send the message + $this->mailer->send($this->message); + } + + /** + * @return null|array
+ */ + public function getReceiver(): ?array + { + if (empty($this->settingsBag->get('email_sender'))) { + throw new \InvalidArgumentException('Main "email_sender" is not configured for this website.'); + } + $defaultReceivers = [new Address($this->settingsBag->get('email_sender'))]; + return parent::getReceiver() ?? $defaultReceivers; + } + + /** + * Gets the value of redirectUrl. + * + * @return string|null + */ + public function getRedirectUrl(): ?string + { + return $this->redirectUrl; + } + + /** + * Sets the value of redirectUrl. + * + * @param string|null $redirectUrl Redirect url + * + * @return self + */ + public function setRedirectUrl(?string $redirectUrl) + { + $this->redirectUrl = $redirectUrl; + + return $this; + } + + /** + * Gets the value of maxFileSize. + * + * @return int + */ + public function getMaxFileSize() + { + return $this->maxFileSize; + } + + /** + * Sets the value of maxFileSize. + * + * @param int $maxFileSize the max file size + * + * @return self + */ + public function setMaxFileSize($maxFileSize) + { + $this->maxFileSize = (int) $maxFileSize; + + return $this; + } + + /** + * Gets the value of allowedMimeTypes. + * + * @return array + */ + public function getAllowedMimeTypes() + { + return $this->allowedMimeTypes; + } + + /** + * Sets the value of allowedMimeTypes. + * + * @param array $allowedMimeTypes the allowed mime types + * + * @return self + */ + public function setAllowedMimeTypes(array $allowedMimeTypes) + { + $this->allowedMimeTypes = $allowedMimeTypes; + + return $this; + } + + /** + * @return array + */ + public function getOptions() + { + return $this->options; + } + + /** + * @param array $options + * + * @return ContactFormManager + */ + public function setOptions($options) + { + $this->options = $options; + + return $this; + } + + /** + * @return bool + */ + public function useRealResponseCode(): bool + { + return $this->useRealResponseCode; + } + + /** + * @param bool $useRealResponseCode Return a real 400 response if form is not valid. + * @return ContactFormManager + */ + public function setUseRealResponseCode(bool $useRealResponseCode): ContactFormManager + { + $this->useRealResponseCode = $useRealResponseCode; + return $this; + } +} diff --git a/lib/RoadizCoreBundle/src/Mailer/EmailManager.php b/lib/RoadizCoreBundle/src/Mailer/EmailManager.php new file mode 100644 index 00000000..346ebf60 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Mailer/EmailManager.php @@ -0,0 +1,610 @@ +requestStack = $requestStack; + $this->translator = $translator; + $this->mailer = $mailer; + $this->templating = $templating; + $this->assignation = []; + $this->message = null; + + /* + * Sets a default CSS for emails. + */ + $this->emailStylesheet = dirname(__DIR__) . '/../css/transactionalStyles.css'; + $this->settingsBag = $settingsBag; + $this->documentUrlGenerator = $documentUrlGenerator; + } + + /** + * @return string + * @throws LoaderError + * @throws RuntimeError + * @throws SyntaxError + */ + public function renderHtmlEmailBody(): string + { + return $this->templating->render($this->getEmailTemplate(), $this->assignation); + } + + /** + * @return string + * @throws LoaderError + * @throws RuntimeError + * @throws SyntaxError + */ + public function renderHtmlEmailBodyWithCss(): string + { + if (null !== $this->getEmailStylesheet()) { + $htmldoc = new InlineStyle($this->renderHtmlEmailBody()); + $htmldoc->applyStylesheet(file_get_contents( + $this->getEmailStylesheet() + )); + + return $htmldoc->getHTML(); + } + + return $this->renderHtmlEmailBody(); + } + + /** + * @return string + * @throws LoaderError + * @throws RuntimeError + * @throws SyntaxError + */ + public function renderPlainTextEmailBody(): string + { + return $this->templating->render($this->getEmailPlainTextTemplate(), $this->assignation); + } + + /** + * Added mainColor and headerImageSrc assignation + * to display email header. + * + * @return $this + */ + public function appendWebsiteIcon(): static + { + if (empty($this->assignation['mainColor']) && null !== $this->settingsBag) { + $this->assignation['mainColor'] = $this->settingsBag->get('main_color'); + } + + if (empty($this->assignation['headerImageSrc']) && null !== $this->settingsBag) { + $adminImage = $this->settingsBag->getDocument('admin_image'); + if ($adminImage instanceof DocumentInterface && null !== $this->documentUrlGenerator) { + $this->documentUrlGenerator->setDocument($adminImage); + $this->assignation['headerImageSrc'] = $this->documentUrlGenerator->getUrl(true); + } + } + + return $this; + } + + /** + * @return Email + * @throws LoaderError + * @throws RuntimeError + * @throws SyntaxError + */ + public function createMessage(): Email + { + $this->appendWebsiteIcon(); + + $this->message = (new Email()) + ->subject($this->getSubject()) + ->from($this->getOrigin()) + ->to(...$this->getReceiver()) + // Force using string and only one email + ->returnPath($this->getSenderEmail()); + + if (null !== $this->getEmailTemplate()) { + $this->message->html($this->renderHtmlEmailBodyWithCss()); + } + if (null !== $this->getEmailPlainTextTemplate()) { + $this->message->text($this->renderPlainTextEmailBody()); + } + + /* + * Use sender email in ReplyTo: header only + * to keep From: header with a know domain email. + */ + if (null !== $this->getSender()) { + $this->message->replyTo(...$this->getSender()); + } + + return $this->message; + } + + /** + * Send email. + * + * @return void + * @throws TransportExceptionInterface + * @throws LoaderError + * @throws RuntimeError + * @throws SyntaxError + */ + public function send(): void + { + if (empty($this->assignation)) { + throw new \RuntimeException("Can’t send a contact form without data."); + } + + if (null === $this->message) { + $this->message = $this->createMessage(); + } + + /* + * File attachment requires local file storage. + */ + foreach ($this->files as $file) { + $this->message->attachFromPath($file->getRealPath(), $file->getFilename()); + } + foreach ($this->resources as $resourceArray) { + [$resource, $filename, $mimeType] = $resourceArray; + $this->message->attach($resource, $filename, $mimeType); + } + + // Send the message + $this->mailer->send($this->message); + } + + /** + * @return null|string + */ + public function getSubject(): ?string + { + return null !== $this->subject ? trim(strip_tags($this->subject)) : null; + } + + /** + * @param null|string $subject + * @return $this + */ + public function setSubject(?string $subject): static + { + $this->subject = $subject; + return $this; + } + + /** + * @return null|string + */ + public function getEmailTitle(): ?string + { + return null !== $this->emailTitle ? trim(strip_tags($this->emailTitle)) : null; + } + + /** + * @param null|string $emailTitle + * @return $this + */ + public function setEmailTitle(?string $emailTitle): static + { + $this->emailTitle = $emailTitle; + return $this; + } + + /** + * Message destination email(s). + * + * @return null|Address[] + */ + public function getReceiver(): ?array + { + return $this->receiver; + } + + /** + * Return only one email as string. + * + * @return null|string + */ + public function getReceiverEmail(): ?string + { + if (is_array($this->getReceiver()) && count($this->getReceiver()) > 0) { + return $this->getReceiver()[0]->getAddress(); + } + + return null; + } + + /** + * Sets the value of receiver. + * + * @param Address|string|array|array
$receiver the receiver + * + * @return $this + * @throws \Exception + */ + public function setReceiver($receiver): static + { + if ($receiver instanceof Address) { + $this->receiver = [$receiver]; + } elseif (\is_string($receiver)) { + $this->receiver = [new Address($receiver)]; + } elseif (\is_array($receiver)) { + $this->receiver = []; + foreach ($receiver as $email => $name) { + if ($name instanceof Address) { + $this->receiver[] = $name; + } elseif (\is_string($email)) { + $this->receiver[] = new Address($email, $name); + } else { + $this->receiver[] = new Address($name); + } + } + } + + return $this; + } + + /** + * Message virtual sender email. + * + * This email will be used as ReplyTo: and ReturnPath: + * + * @return null|Address[] + */ + public function getSender(): ?array + { + return $this->sender; + } + + /** + * Return only one email as string. + * + * @return null|string + */ + public function getSenderEmail(): ?string + { + if (\is_array($this->sender) && \count($this->sender) > 0) { + return $this->sender[0]->getAddress(); + } + + return null; + } + + /** + * Sets the value of sender. + * + * @param Address|string|array|array $sender + * @return $this + * @throws \Exception + */ + public function setSender($sender): static + { + if ($sender instanceof Address) { + $this->sender = [$sender]; + } elseif (\is_string($sender)) { + $this->sender = [new Address($sender)]; + } elseif (\is_array($sender)) { + $this->sender = []; + foreach ($sender as $email => $name) { + if ($name instanceof Address) { + $this->sender[] = $name; + } elseif (\is_string($email)) { + $this->sender[] = new Address($email, $name); + } else { + $this->sender[] = new Address($name); + } + } + } else { + throw new \InvalidArgumentException('Sender should be string or array'); + } + + return $this; + } + + /** + * @return string + */ + public function getSuccessMessage(): string + { + return $this->successMessage; + } + + /** + * @param string $successMessage + * @return $this + */ + public function setSuccessMessage(string $successMessage): static + { + $this->successMessage = $successMessage; + return $this; + } + + /** + * @return string + */ + public function getFailMessage(): string + { + return $this->failMessage; + } + + /** + * @param string $failMessage + * @return $this + */ + public function setFailMessage(string $failMessage): static + { + $this->failMessage = $failMessage; + return $this; + } + + /** + * @return TranslatorInterface + */ + public function getTranslator(): TranslatorInterface + { + return $this->translator; + } + + /** + * @param TranslatorInterface $translator + * @return $this + */ + public function setTranslator(TranslatorInterface $translator): static + { + $this->translator = $translator; + return $this; + } + + /** + * @return Environment + */ + public function getTemplating(): Environment + { + return $this->templating; + } + + /** + * @param Environment $templating + * @return $this + */ + public function setTemplating(Environment $templating): static + { + $this->templating = $templating; + return $this; + } + + /** + * @return MailerInterface + */ + public function getMailer(): MailerInterface + { + return $this->mailer; + } + + /** + * @param MailerInterface $mailer + * @return $this + */ + public function setMailer(MailerInterface $mailer): static + { + $this->mailer = $mailer; + return $this; + } + + /** + * @return string|null + */ + public function getEmailTemplate(): ?string + { + return $this->emailTemplate; + } + + /** + * @param string|null $emailTemplate + * @return $this + */ + public function setEmailTemplate(?string $emailTemplate = null): static + { + $this->emailTemplate = $emailTemplate; + return $this; + } + + /** + * @return string|null + */ + public function getEmailPlainTextTemplate(): ?string + { + return $this->emailPlainTextTemplate; + } + + /** + * @param string|null $emailPlainTextTemplate + * @return $this + */ + public function setEmailPlainTextTemplate(?string $emailPlainTextTemplate = null): static + { + $this->emailPlainTextTemplate = $emailPlainTextTemplate; + return $this; + } + + /** + * @return string|null + */ + public function getEmailStylesheet(): ?string + { + return $this->emailStylesheet; + } + + /** + * @param string|null $emailStylesheet + * @return $this + */ + public function setEmailStylesheet(?string $emailStylesheet = null): static + { + $this->emailStylesheet = $emailStylesheet; + return $this; + } + + /** + * @return Request + */ + public function getRequest(): Request + { + return $this->requestStack->getMainRequest(); + } + + /** + * Origin is the real From envelop. + * + * This must be an email address with a know + * domain name to be validated on your SMTP server. + * + * @return null|Address + */ + public function getOrigin(): ?Address + { + $defaultSender = 'origin@roadiz.io'; + $defaultSenderName = ''; + if (null !== $this->settingsBag && $this->settingsBag->get('email_sender')) { + $defaultSender = $this->settingsBag->get('email_sender'); + $defaultSenderName = $this->settingsBag->get('site_name', '') ?? ''; + } + return $this->origin ?? new Address($defaultSender, $defaultSenderName); + } + + /** + * @param string $origin + * @return $this + */ + public function setOrigin(string $origin): static + { + $this->origin = new Address($origin); + return $this; + } + + /** + * @return array + */ + public function getAssignation(): array + { + return $this->assignation; + } + + /** + * @param array $assignation + * @return $this + */ + public function setAssignation(array $assignation): static + { + $this->assignation = $assignation; + return $this; + } + + /** + * @return null|string + */ + public function getEmailType(): ?string + { + return $this->emailType; + } + + /** + * @param null|string $emailType + * @return $this + */ + public function setEmailType(?string $emailType): static + { + $this->emailType = $emailType; + return $this; + } + + /** + * @return File[] + */ + public function getFiles(): array + { + return $this->files; + } + + /** + * @param File[] $files + * @return $this + */ + public function setFiles(array $files): static + { + $this->files = $files; + return $this; + } + + /** + * @return array [$resource, $filename, $mimeType] + */ + public function getResources(): array + { + return $this->resources; + } + + /** + * @param resource $resource + * @param string $filename + * @param string $mimeType + * @return $this + */ + public function addResource($resource, string $filename, string $mimeType): static + { + $this->resources[] = [$resource, $filename, $mimeType]; + return $this; + } +} diff --git a/lib/RoadizCoreBundle/src/Message/ApplyRealmNodeInheritanceMessage.php b/lib/RoadizCoreBundle/src/Message/ApplyRealmNodeInheritanceMessage.php new file mode 100644 index 00000000..7159e176 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Message/ApplyRealmNodeInheritanceMessage.php @@ -0,0 +1,33 @@ +nodeId = $nodeId; + $this->realmId = $realmId; + } + + /** + * @return int + */ + public function getNodeId(): int + { + return $this->nodeId; + } + + /** + * @return int|null + */ + public function getRealmId(): ?int + { + return $this->realmId; + } +} diff --git a/lib/RoadizCoreBundle/src/Message/AsyncMessage.php b/lib/RoadizCoreBundle/src/Message/AsyncMessage.php new file mode 100644 index 00000000..7a7edfae --- /dev/null +++ b/lib/RoadizCoreBundle/src/Message/AsyncMessage.php @@ -0,0 +1,9 @@ +nodeId = $nodeId; + $this->realmId = $realmId; + } + + /** + * @return int + */ + public function getNodeId(): int + { + return $this->nodeId; + } + + /** + * @return int|null + */ + public function getRealmId(): ?int + { + return $this->realmId; + } +} diff --git a/lib/RoadizCoreBundle/src/Message/DeleteNodeTypeMessage.php b/lib/RoadizCoreBundle/src/Message/DeleteNodeTypeMessage.php new file mode 100644 index 00000000..a5f3c682 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Message/DeleteNodeTypeMessage.php @@ -0,0 +1,26 @@ +nodeTypeId = $nodeTypeId; + } + + /** + * @return int + */ + public function getNodeTypeId(): int + { + return $this->nodeTypeId; + } +} diff --git a/lib/RoadizCoreBundle/src/Message/GuzzleRequestMessage.php b/lib/RoadizCoreBundle/src/Message/GuzzleRequestMessage.php new file mode 100644 index 00000000..458e8c8e --- /dev/null +++ b/lib/RoadizCoreBundle/src/Message/GuzzleRequestMessage.php @@ -0,0 +1,42 @@ +request = $request; + $this->options = array_merge([ + 'debug' => false, + 'timeout' => 3 + ], $options); + } + + /** + * @return RequestInterface + */ + public function getRequest(): RequestInterface + { + return $this->request; + } + + /** + * @return array + */ + public function getOptions(): array + { + return $this->options; + } +} diff --git a/lib/RoadizCoreBundle/src/Message/Handler/ApplyRealmNodeInheritanceMessageHandler.php b/lib/RoadizCoreBundle/src/Message/Handler/ApplyRealmNodeInheritanceMessageHandler.php new file mode 100644 index 00000000..785eb73d --- /dev/null +++ b/lib/RoadizCoreBundle/src/Message/Handler/ApplyRealmNodeInheritanceMessageHandler.php @@ -0,0 +1,85 @@ +managerRegistry = $managerRegistry; + $this->handlerFactory = $handlerFactory; + } + + public function __invoke(ApplyRealmNodeInheritanceMessage $message): void + { + if ($message->getRealmId() === null) { + return; + } + $node = $this->managerRegistry->getRepository(Node::class)->find($message->getNodeId()); + $realm = $this->managerRegistry->getRepository(Realm::class)->find($message->getRealmId()); + + if (null === $node) { + throw new UnrecoverableMessageHandlingException('Node does not exist'); + } + if (null === $realm) { + throw new UnrecoverableMessageHandlingException('Realm does not exist'); + } + + $realmNode = $this->managerRegistry->getRepository(RealmNode::class)->findOneBy([ + 'node' => $node, + 'realm' => $realm, + ]); + + /* + * Do not propagate if realm node inheritance type is not ROOT + */ + if (null === $realmNode || $realmNode->getInheritanceType() !== RealmInterface::INHERITANCE_ROOT) { + return; + } + + /** @var NodeHandler $nodeHandler */ + $nodeHandler = $this->handlerFactory->getHandler($node); + $childrenIds = $nodeHandler->getAllOffspringId(); + + foreach ($childrenIds as $childId) { + /** @var Node|null $child */ + $child = $this->managerRegistry + ->getRepository(Node::class) + ->find($childId); + if (null === $child) { + continue; + } + + /** @var RealmNode|null $childRealmNode */ + $childRealmNode = $this->managerRegistry->getRepository(RealmNode::class)->findOneBy([ + 'node' => $child, + 'realm' => $realm, + ]); + if (null === $childRealmNode) { + $childRealmNode = new RealmNode(); + $childRealmNode->setNode($child); + $childRealmNode->setRealm($realm); + $childRealmNode->setInheritanceType(RealmInterface::INHERITANCE_AUTO); + $this->managerRegistry->getManager()->persist($childRealmNode); + } + } + + $this->managerRegistry->getManager()->flush(); + } +} diff --git a/lib/RoadizCoreBundle/src/Message/Handler/CleanRealmNodeInheritanceMessageHandler.php b/lib/RoadizCoreBundle/src/Message/Handler/CleanRealmNodeInheritanceMessageHandler.php new file mode 100644 index 00000000..b9d01fd6 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Message/Handler/CleanRealmNodeInheritanceMessageHandler.php @@ -0,0 +1,60 @@ +managerRegistry = $managerRegistry; + $this->handlerFactory = $handlerFactory; + } + + public function __invoke(CleanRealmNodeInheritanceMessage $message): void + { + if ($message->getRealmId() === null) { + return; + } + $node = $this->managerRegistry->getRepository(Node::class)->find($message->getNodeId()); + $realm = $this->managerRegistry->getRepository(Realm::class)->find($message->getRealmId()); + + if (null === $node) { + throw new UnrecoverableMessageHandlingException('Node does not exist'); + } + if (null === $realm) { + throw new UnrecoverableMessageHandlingException('Realm does not exist'); + } + + /** @var NodeHandler $nodeHandler */ + $nodeHandler = $this->handlerFactory->getHandler($node); + $childrenIds = $nodeHandler->getAllOffspringId(); + + $realmNodes = $this->managerRegistry + ->getRepository(RealmNode::class) + ->findByNodeIdsAndRealmId( + $childrenIds, + $message->getRealmId() + ); + + foreach ($realmNodes as $realmNode) { + $this->managerRegistry->getManager()->remove($realmNode); + } + + $this->managerRegistry->getManager()->flush(); + } +} diff --git a/lib/RoadizCoreBundle/src/Message/Handler/DeleteNodeTypeMessageHandler.php b/lib/RoadizCoreBundle/src/Message/Handler/DeleteNodeTypeMessageHandler.php new file mode 100644 index 00000000..6159b918 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Message/Handler/DeleteNodeTypeMessageHandler.php @@ -0,0 +1,54 @@ +managerRegistry = $managerRegistry; + $this->handlerFactory = $handlerFactory; + $this->messageBus = $messageBus; + } + + /** + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ + public function __invoke(DeleteNodeTypeMessage $message): void + { + $nodeType = $this->managerRegistry->getRepository(NodeType::class)->find($message->getNodeTypeId()); + + if (!$nodeType instanceof NodeType) { + throw new UnrecoverableMessageHandlingException('NodeType does not exist'); + } + + /** @var NodeTypeHandler $handler */ + $handler = $this->handlerFactory->getHandler($nodeType); + $handler->deleteWithAssociations(); + + $this->messageBus->dispatch( + (new Envelope(new UpdateDoctrineSchemaMessage())) + ); + $this->managerRegistry->getManager()->clear(); + } +} diff --git a/lib/RoadizCoreBundle/src/Message/Handler/HttpRequestMessageHandler.php b/lib/RoadizCoreBundle/src/Message/Handler/HttpRequestMessageHandler.php new file mode 100644 index 00000000..b7848ed5 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Message/Handler/HttpRequestMessageHandler.php @@ -0,0 +1,43 @@ +logger = $logger ?? new NullLogger(); + $this->client = $client ?? new Client(); + } + + public function __invoke(HttpRequestMessage $message): void + { + try { + $this->logger->debug(sprintf( + 'HTTP request executed: %s %s', + $message->getRequest()->getMethod(), + $message->getRequest()->getUri() + )); + $this->client->send($message->getRequest(), $message->getOptions()); + } catch (GuzzleException $exception) { + throw new UnrecoverableMessageHandlingException($exception->getMessage(), 0, $exception); + } + } +} diff --git a/lib/RoadizCoreBundle/src/Message/Handler/PurgeReverseProxyCacheMessageHandler.php b/lib/RoadizCoreBundle/src/Message/Handler/PurgeReverseProxyCacheMessageHandler.php new file mode 100644 index 00000000..6082040d --- /dev/null +++ b/lib/RoadizCoreBundle/src/Message/Handler/PurgeReverseProxyCacheMessageHandler.php @@ -0,0 +1,108 @@ +urlGenerator = $urlGenerator; + $this->reverseProxyCacheLocator = $reverseProxyCacheLocator; + $this->managerRegistry = $managerRegistry; + $this->bus = $bus; + } + + public function __invoke(PurgeReverseProxyCacheMessage $message): void + { + $nodeSource = $this->managerRegistry + ->getRepository(NodesSources::class) + ->find($message->getNodeSourceId()); + if (null === $nodeSource) { + throw new UnrecoverableMessageHandlingException('NodesSources does not exist anymore.'); + } + + while (!$nodeSource->isReachable()) { + $nodeSource = $nodeSource->getParent(); + if (null === $nodeSource) { + return; + } + } + + $purgeRequests = $this->createPurgeRequests($this->urlGenerator->generate( + RouteObjectInterface::OBJECT_BASED_ROUTE_NAME, + [ + RouteObjectInterface::ROUTE_OBJECT => $nodeSource, + ] + )); + foreach ($purgeRequests as $request) { + $this->sendRequest($request); + } + } + + /** + * @param string $path + * + * @return \GuzzleHttp\Psr7\Request[] + */ + protected function createPurgeRequests(string $path = "/"): array + { + $requests = []; + foreach ($this->reverseProxyCacheLocator->getFrontends() as $frontend) { + $requests[$frontend->getName()] = new \GuzzleHttp\Psr7\Request( + Request::METHOD_PURGE, + 'http://' . $frontend->getHost() . $path, + [ + 'Host' => $frontend->getDomainName() + ] + ); + } + return $requests; + } + + /** + * @param \GuzzleHttp\Psr7\Request $request + * @return void + */ + protected function sendRequest(\GuzzleHttp\Psr7\Request $request): void + { + try { + $this->bus->dispatch(new Envelope(new GuzzleRequestMessage($request, [ + 'debug' => false, + 'timeout' => 3 + ]))); + } catch (NoHandlerForMessageException $exception) { + throw new UnrecoverableMessageHandlingException($exception->getMessage(), 0, $exception); + } + } +} diff --git a/lib/RoadizCoreBundle/src/Message/Handler/SearchRealmNodeInheritanceMessageHandler.php b/lib/RoadizCoreBundle/src/Message/Handler/SearchRealmNodeInheritanceMessageHandler.php new file mode 100644 index 00000000..145bc5e8 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Message/Handler/SearchRealmNodeInheritanceMessageHandler.php @@ -0,0 +1,98 @@ +managerRegistry = $managerRegistry; + $this->handlerFactory = $handlerFactory; + $this->bus = $bus; + $this->logger = $logger; + } + + public function __invoke(SearchRealmNodeInheritanceMessage $message): void + { + /** @var Node|null $node */ + $node = $this->managerRegistry->getRepository(Node::class)->find($message->getNodeId()); + if (null === $node) { + throw new UnrecoverableMessageHandlingException('Node does not exist'); + } + + $this->clearAnyExistingRealmNodes($node); + $this->applyRootRealmNodes($node); + } + + private function clearAnyExistingRealmNodes(Node $node): void + { + /** @var RealmNode[] $autoRealmNodes */ + $autoRealmNodes = $this->managerRegistry->getRepository(RealmNode::class)->findBy([ + 'node' => $node, + 'inheritanceType' => RealmInterface::INHERITANCE_AUTO + ]); + + /* + * If there are existing auto realmNode from former ancestor, we need to clean them + */ + foreach ($autoRealmNodes as $autoRealmNode) { + $this->logger->info('Clean existing RealmNode information'); + $this->bus->dispatch(new Envelope(new CleanRealmNodeInheritanceMessage( + $autoRealmNode->getNode()->getId(), + null !== $autoRealmNode->getRealm() ? $autoRealmNode->getRealm()->getId() : null + ))); + } + } + + private function applyRootRealmNodes(Node $node): void + { + /** @var NodeHandler $nodeHandler */ + $nodeHandler = $this->handlerFactory->getHandler($node); + $parents = $nodeHandler->getParents(); + + if (count($parents) === 0) { + return; + } + + foreach ($parents as $parent) { + /** @var RealmNode[] $rootRealmNodes */ + $rootRealmNodes = $this->managerRegistry->getRepository(RealmNode::class)->findBy([ + 'node' => $parent, + 'inheritanceType' => RealmInterface::INHERITANCE_ROOT, + ]); + foreach ($rootRealmNodes as $rootRealmNode) { + $this->logger->info('Apply new root RealmNode information'); + $this->bus->dispatch(new Envelope(new ApplyRealmNodeInheritanceMessage( + $rootRealmNode->getNode()->getId(), + null !== $rootRealmNode->getRealm() ? $rootRealmNode->getRealm()->getId() : null + ))); + } + } + } +} diff --git a/lib/RoadizCoreBundle/src/Message/Handler/UpdateDoctrineSchemaMessageHandler.php b/lib/RoadizCoreBundle/src/Message/Handler/UpdateDoctrineSchemaMessageHandler.php new file mode 100644 index 00000000..e9c384f5 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Message/Handler/UpdateDoctrineSchemaMessageHandler.php @@ -0,0 +1,28 @@ +schemaUpdater = $schemaUpdater; + } + + /** + * @throws \Exception + */ + public function __invoke(UpdateDoctrineSchemaMessage $message): void + { + $this->schemaUpdater->updateNodeTypesSchema(); + $this->schemaUpdater->clearAllCaches(); + } +} diff --git a/lib/RoadizCoreBundle/src/Message/Handler/UpdateNodeTypeSchemaMessageHandler.php b/lib/RoadizCoreBundle/src/Message/Handler/UpdateNodeTypeSchemaMessageHandler.php new file mode 100644 index 00000000..6a0185ec --- /dev/null +++ b/lib/RoadizCoreBundle/src/Message/Handler/UpdateNodeTypeSchemaMessageHandler.php @@ -0,0 +1,48 @@ +managerRegistry = $managerRegistry; + $this->handlerFactory = $handlerFactory; + $this->messageBus = $messageBus; + } + + public function __invoke(UpdateNodeTypeSchemaMessage $message): void + { + $nodeType = $this->managerRegistry->getRepository(NodeType::class)->find($message->getNodeTypeId()); + + if (!$nodeType instanceof NodeType) { + throw new UnrecoverableMessageHandlingException('NodeType does not exist'); + } + + /** @var NodeTypeHandler $handler */ + $handler = $this->handlerFactory->getHandler($nodeType); + $handler->updateSchema(); + + $this->managerRegistry->getManager()->clear(); + $this->messageBus->dispatch( + (new Envelope(new UpdateDoctrineSchemaMessage())) + ); + } +} diff --git a/lib/RoadizCoreBundle/src/Message/HttpRequestMessage.php b/lib/RoadizCoreBundle/src/Message/HttpRequestMessage.php new file mode 100644 index 00000000..1e021314 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Message/HttpRequestMessage.php @@ -0,0 +1,13 @@ +nodeSourceId = $nodeSourceId; + } + + /** + * @return int + */ + public function getNodeSourceId(): int + { + return $this->nodeSourceId; + } +} diff --git a/lib/RoadizCoreBundle/src/Message/SearchRealmNodeInheritanceMessage.php b/lib/RoadizCoreBundle/src/Message/SearchRealmNodeInheritanceMessage.php new file mode 100644 index 00000000..e3732026 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Message/SearchRealmNodeInheritanceMessage.php @@ -0,0 +1,23 @@ +nodeId = $nodeId; + } + + /** + * @return int + */ + public function getNodeId(): int + { + return $this->nodeId; + } +} diff --git a/lib/RoadizCoreBundle/src/Message/UpdateDoctrineSchemaMessage.php b/lib/RoadizCoreBundle/src/Message/UpdateDoctrineSchemaMessage.php new file mode 100644 index 00000000..d689ed7a --- /dev/null +++ b/lib/RoadizCoreBundle/src/Message/UpdateDoctrineSchemaMessage.php @@ -0,0 +1,9 @@ +nodeTypeId = $nodeTypeId; + } + + /** + * @return int + */ + public function getNodeTypeId(): int + { + return $this->nodeTypeId; + } +} diff --git a/lib/RoadizCoreBundle/src/Model/AttributableInterface.php b/lib/RoadizCoreBundle/src/Model/AttributableInterface.php new file mode 100644 index 00000000..39ba3537 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Model/AttributableInterface.php @@ -0,0 +1,52 @@ + + */ + public function getAttributesValuesForTranslation(TranslationInterface $translation): Collection; + + /** + * @param TranslationInterface $translation + * + * @return Collection + */ + public function getAttributesValuesTranslations(TranslationInterface $translation): Collection; + + /** + * @param Collection $attributes + * + * @return $this + */ + public function setAttributeValues(Collection $attributes): static; + + /** + * @param AttributeValueInterface $attribute + * + * @return $this + */ + public function addAttributeValue(AttributeValueInterface $attribute): static; + + /** + * @param AttributeValueInterface $attribute + * + * @return $this + */ + public function removeAttributeValue(AttributeValueInterface $attribute): static; +} diff --git a/lib/RoadizCoreBundle/src/Model/AttributableTrait.php b/lib/RoadizCoreBundle/src/Model/AttributableTrait.php new file mode 100644 index 00000000..0fa41d94 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Model/AttributableTrait.php @@ -0,0 +1,100 @@ + + */ + public function getAttributeValues(): Collection + { + return $this->attributeValues; + } + + /** + * @param TranslationInterface $translation + * + * @return Collection + */ + public function getAttributesValuesForTranslation(TranslationInterface $translation): Collection + { + return $this->getAttributeValues()->filter(function (AttributeValueInterface $attributeValue) use ($translation) { + /** @var AttributeValueTranslationInterface $attributeValueTranslation */ + foreach ($attributeValue->getAttributeValueTranslations() as $attributeValueTranslation) { + if ($attributeValueTranslation->getTranslation() === $translation) { + return true; + } + } + return false; + }); + } + + /** + * @param TranslationInterface $translation + * + * @return Collection + */ + public function getAttributesValuesTranslations(TranslationInterface $translation): Collection + { + /** @var Collection $values */ + $values = $this->getAttributesValuesForTranslation($translation) + ->map(function (AttributeValueInterface $attributeValue) use ($translation) { + /** @var AttributeValueTranslationInterface $attributeValueTranslation */ + foreach ($attributeValue->getAttributeValueTranslations() as $attributeValueTranslation) { + if ($attributeValueTranslation->getTranslation() === $translation) { + return $attributeValueTranslation; + } + } + return null; + }) + ->filter(function (?AttributeValueTranslationInterface $attributeValueTranslation) { + return null !== $attributeValueTranslation; + }) + ; + return $values; // phpstan does not understand return type after filtering + } + + /** + * @param Collection $attributes + * + * @return $this + */ + public function setAttributeValues(Collection $attributes): static + { + $this->attributeValues = $attributes; + return $this; + } + + /** + * @param AttributeValueInterface $attribute + * + * @return $this + */ + public function addAttributeValue(AttributeValueInterface $attribute): static + { + if (!$this->getAttributeValues()->contains($attribute)) { + $this->getAttributeValues()->add($attribute); + } + return $this; + } + + + /** + * @param AttributeValueInterface $attribute + * + * @return $this + */ + public function removeAttributeValue(AttributeValueInterface $attribute): static + { + if ($this->getAttributeValues()->contains($attribute)) { + $this->getAttributeValues()->removeElement($attribute); + } + return $this; + } +} diff --git a/lib/RoadizCoreBundle/src/Model/AttributeGroupInterface.php b/lib/RoadizCoreBundle/src/Model/AttributeGroupInterface.php new file mode 100644 index 00000000..18081048 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Model/AttributeGroupInterface.php @@ -0,0 +1,25 @@ + + */ + #[ + ORM\OneToMany(mappedBy: "group", targetEntity: AttributeInterface::class), + Serializer\Groups(["attribute_group"]), + Serializer\Type("ArrayCollection") + ] + protected Collection $attributes; + + /** + * @var Collection + */ + #[ + ORM\OneToMany(mappedBy: "attributeGroup", targetEntity: AttributeGroupTranslationInterface::class, cascade: ["all"]), + Serializer\Groups(["attribute_group", "attribute", "node", "nodes_sources"]), + Serializer\Type("ArrayCollection"), + Serializer\Accessor(getter: "getAttributeGroupTranslations", setter: "setAttributeGroupTranslations") + ] + protected Collection $attributeGroupTranslations; + + public function getName(): ?string + { + if ($this->getAttributeGroupTranslations()->first()) { + return $this->getAttributeGroupTranslations()->first()->getName(); + } + return $this->getCanonicalName(); + } + + public function getTranslatedName(?TranslationInterface $translation): ?string + { + if (null === $translation) { + return $this->getName(); + } + + $attributeGroupTranslation = $this->getAttributeGroupTranslations()->filter( + function (AttributeGroupTranslationInterface $attributeGroupTranslation) use ($translation) { + if ($attributeGroupTranslation->getTranslation() === $translation) { + return true; + } + return false; + } + ); + if ($attributeGroupTranslation->count() > 0 && $attributeGroupTranslation->first()->getName() !== '') { + return $attributeGroupTranslation->first()->getName(); + } + return $this->getCanonicalName(); + } + + public function setName(?string $name): self + { + if ($this->getAttributeGroupTranslations()->count() === 0) { + $this->getAttributeGroupTranslations()->add( + $this->createAttributeGroupTranslation()->setName($name) + ); + } + + $this->canonicalName = StringHandler::slugify($name ?? ''); + return $this; + } + + public function getCanonicalName(): ?string + { + return $this->canonicalName; + } + + /** + * @param string|null $canonicalName + * @return $this + */ + public function setCanonicalName(?string $canonicalName): self + { + $this->canonicalName = StringHandler::slugify($canonicalName ?? ''); + return $this; + } + + public function getAttributes(): Collection + { + return $this->attributes; + } + + /** + * @param Collection $attributes + * @return $this + */ + public function setAttributes(Collection $attributes): self + { + $this->attributes = $attributes; + return $this; + } + + public function getAttributeGroupTranslations(): Collection + { + return $this->attributeGroupTranslations; + } + + /** + * @param Collection $attributeGroupTranslations + * @return $this + */ + public function setAttributeGroupTranslations(Collection $attributeGroupTranslations): self + { + $this->attributeGroupTranslations = $attributeGroupTranslations; + /** @var AttributeGroupTranslationInterface $attributeGroupTranslation */ + foreach ($this->attributeGroupTranslations as $attributeGroupTranslation) { + $attributeGroupTranslation->setAttributeGroup($this); + } + return $this; + } + + /** + * @param AttributeGroupTranslationInterface $attributeGroupTranslation + * @return $this + */ + public function addAttributeGroupTranslation(AttributeGroupTranslationInterface $attributeGroupTranslation): self + { + if (!$this->getAttributeGroupTranslations()->contains($attributeGroupTranslation)) { + $this->getAttributeGroupTranslations()->add($attributeGroupTranslation); + $attributeGroupTranslation->setAttributeGroup($this); + } + return $this; + } + + /** + * @param AttributeGroupTranslationInterface $attributeGroupTranslation + * @return $this + */ + public function removeAttributeGroupTranslation(AttributeGroupTranslationInterface $attributeGroupTranslation): self + { + if ($this->getAttributeGroupTranslations()->contains($attributeGroupTranslation)) { + $this->getAttributeGroupTranslations()->removeElement($attributeGroupTranslation); + } + return $this; + } + + abstract protected function createAttributeGroupTranslation(): AttributeGroupTranslationInterface; +} diff --git a/lib/RoadizCoreBundle/src/Model/AttributeGroupTranslationInterface.php b/lib/RoadizCoreBundle/src/Model/AttributeGroupTranslationInterface.php new file mode 100644 index 00000000..9371733a --- /dev/null +++ b/lib/RoadizCoreBundle/src/Model/AttributeGroupTranslationInterface.php @@ -0,0 +1,47 @@ +name; + } + + /** + * @param string $value + * + * @return self + */ + public function setName(string $value) + { + $this->name = $value; + return $this; + } + + /** + * @param TranslationInterface $translation + * + * @return mixed + */ + public function setTranslation(TranslationInterface $translation) + { + $this->translation = $translation; + return $this; + } + + /** + * @return TranslationInterface|null + */ + public function getTranslation(): ?TranslationInterface + { + return $this->translation; + } + + /** + * @return AttributeGroupInterface + */ + public function getAttributeGroup(): AttributeGroupInterface + { + return $this->attributeGroup; + } + + /** + * @param AttributeGroupInterface $attributeGroup + * + * @return mixed + */ + public function setAttributeGroup(AttributeGroupInterface $attributeGroup) + { + $this->attributeGroup = $attributeGroup; + return $this; + } +} diff --git a/lib/RoadizCoreBundle/src/Model/AttributeInterface.php b/lib/RoadizCoreBundle/src/Model/AttributeInterface.php new file mode 100644 index 00000000..0f1c8b99 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Model/AttributeInterface.php @@ -0,0 +1,218 @@ + + */ + public function getAttributeTranslations(): Collection; + + /** + * @param Collection $attributeTranslations + * + * @return $this + */ + public function setAttributeTranslations(Collection $attributeTranslations): self; + + /** + * @param AttributeTranslationInterface $attributeTranslation + * + * @return $this + */ + public function addAttributeTranslation(AttributeTranslationInterface $attributeTranslation): self; + + /** + * @param AttributeTranslationInterface $attributeTranslation + * + * @return $this + */ + public function removeAttributeTranslation(AttributeTranslationInterface $attributeTranslation): self; + + /** + * @return bool + */ + public function isSearchable(): bool; + + /** + * @param bool $searchable + */ + public function setSearchable(bool $searchable): self; + + /** + * @param TranslationInterface $translation + * + * @return array|null + */ + public function getOptions(TranslationInterface $translation): ?array; + + /** + * @return int + */ + public function getType(): int; + + /** + * @return string|null + */ + public function getColor(): ?string; + + /** + * @param string|null $color + */ + public function setColor(?string $color): self; + + /** + * @return AttributeGroupInterface|null + */ + public function getGroup(): ?AttributeGroupInterface; + + /** + * @param AttributeGroupInterface|null $group + * @return $this + */ + public function setGroup(?AttributeGroupInterface $group): self; + + /** + * @return Collection + */ + public function getDocuments(): Collection; + + /** + * @param int $type + * @return $this + */ + public function setType(int $type): self; + + /** + * @return bool + */ + public function isString(): bool; + + /** + * @return bool + */ + public function isDate(): bool; + + /** + * @return bool + */ + public function isDateTime(): bool; + + /** + * @return bool + */ + public function isBoolean(): bool; + + /** + * @return bool + */ + public function isInteger(): bool; + + /** + * @return bool + */ + public function isDecimal(): bool; + + /** + * @return bool + */ + public function isPercent(): bool; + + /** + * @return bool + */ + public function isEmail(): bool; + + /** + * @return bool + */ + public function isColor(): bool; + + /** + * @return bool + */ + public function isColour(): bool; + + /** + * @return bool + */ + public function isEnum(): bool; + + /** + * @return bool + */ + public function isCountry(): bool; +} diff --git a/lib/RoadizCoreBundle/src/Model/AttributeTrait.php b/lib/RoadizCoreBundle/src/Model/AttributeTrait.php new file mode 100644 index 00000000..3b7a2c46 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Model/AttributeTrait.php @@ -0,0 +1,347 @@ + false]), + Serializer\Groups(["attribute"]), + SymfonySerializer\Groups(["attribute"]), + Serializer\Type("boolean") + ] + protected bool $searchable = false; + + #[ + ORM\Column(type: "integer", unique: false, nullable: false), + Serializer\Groups(["attribute"]), + SymfonySerializer\Groups(["attribute"]), + Serializer\Type("integer") + ] + protected int $type = AttributeInterface::STRING_T; + + #[ + ORM\Column(type: "string", length: 7, unique: false, nullable: true), + Serializer\Groups(["attribute", "node", "nodes_sources"]), + SymfonySerializer\Groups(["attribute", "node", "nodes_sources"]), + Serializer\Type("string") + ] + protected ?string $color = null; + + #[ + ORM\ManyToOne( + targetEntity: AttributeGroupInterface::class, + cascade: ["persist", "merge"], + fetch: "EAGER", + inversedBy: "attributes" + ), + ORM\JoinColumn(name: "group_id", onDelete: "SET NULL"), + Serializer\Groups(["attribute", "node", "nodes_sources"]), + SymfonySerializer\Groups(["attribute", "node", "nodes_sources"]), + Serializer\Type("RZ\Roadiz\CoreBundle\Model\AttributeGroupInterface") + ] + protected ?AttributeGroupInterface $group = null; + + /** + * @var Collection + */ + #[ + ORM\OneToMany( + mappedBy: "attribute", + targetEntity: AttributeTranslationInterface::class, + cascade: ["all"], + fetch: "EAGER", + orphanRemoval: true + ), + Serializer\Groups(["attribute", "node", "nodes_sources"]), + SymfonySerializer\Groups(["attribute", "node", "nodes_sources"]), + Serializer\Type("ArrayCollection"), + Serializer\Accessor(getter: "getAttributeTranslations", setter: "setAttributeTranslations") + ] + protected Collection $attributeTranslations; + + /** + * @var Collection + */ + #[ + ORM\OneToMany( + mappedBy: "attribute", + targetEntity: AttributeValueInterface::class, + cascade: ["persist", "remove"], + fetch: "EXTRA_LAZY", + orphanRemoval: true + ), + Serializer\Exclude, + SymfonySerializer\Ignore + ] + protected Collection $attributeValues; + + /** + * @return string + */ + public function getCode(): string + { + return $this->code; + } + + /** + * @param string|null $code + * + * @return $this + */ + public function setCode(?string $code): self + { + $this->code = StringHandler::slugify($code ?? ''); + return $this; + } + + /** + * @return int + */ + public function getType(): int + { + return $this->type; + } + + /** + * @param int $type + * + * @return $this + */ + public function setType(int $type): self + { + $this->type = $type; + return $this; + } + + /** + * @return string|null + */ + public function getColor(): ?string + { + return $this->color; + } + + /** + * @param string|null $color + * + * @return $this + */ + public function setColor(?string $color): self + { + $this->color = $color; + return $this; + } + + /** + * @return AttributeGroupInterface|null + */ + public function getGroup(): ?AttributeGroupInterface + { + return $this->group; + } + + /** + * @param AttributeGroupInterface|null $group + * + * @return $this + */ + public function setGroup(?AttributeGroupInterface $group): self + { + $this->group = $group; + return $this; + } + + /** + * @return bool + */ + public function isSearchable(): bool + { + return (bool) $this->searchable; + } + + /** + * @param bool $searchable + * + * @return $this + */ + public function setSearchable(bool $searchable): self + { + $this->searchable = $searchable; + return $this; + } + + /** + * @param TranslationInterface|null $translation + * + * @return string + */ + public function getLabelOrCode(?TranslationInterface $translation = null): string + { + if (null !== $translation) { + $attributeTranslation = $this->getAttributeTranslations()->filter( + function (AttributeTranslationInterface $attributeTranslation) use ($translation) { + return $attributeTranslation->getTranslation() === $translation; + } + ); + + if ( + $attributeTranslation->first() && + $attributeTranslation->first()->getLabel() !== '' + ) { + return $attributeTranslation->first()->getLabel(); + } + } + + return $this->getCode(); + } + + /** + * @param TranslationInterface $translation + * + * @return array|null + */ + public function getOptions(TranslationInterface $translation): ?array + { + $attributeTranslation = $this->getAttributeTranslations()->filter( + function (AttributeTranslationInterface $attributeTranslation) use ($translation) { + return $attributeTranslation->getTranslation() === $translation; + } + ); + if ($attributeTranslation->count() > 0) { + return $attributeTranslation->first()->getOptions(); + } + + return null; + } + + /** + * @return Collection + */ + public function getAttributeTranslations(): Collection + { + return $this->attributeTranslations; + } + + /** + * @param Collection $attributeTranslations + * + * @return $this + */ + public function setAttributeTranslations(Collection $attributeTranslations): self + { + $this->attributeTranslations = $attributeTranslations; + /** @var AttributeTranslationInterface $attributeTranslation */ + foreach ($this->attributeTranslations as $attributeTranslation) { + $attributeTranslation->setAttribute($this); + } + return $this; + } + + /** + * @param AttributeTranslationInterface $attributeTranslation + * + * @return $this + */ + public function addAttributeTranslation(AttributeTranslationInterface $attributeTranslation): self + { + if (!$this->getAttributeTranslations()->contains($attributeTranslation)) { + $this->getAttributeTranslations()->add($attributeTranslation); + $attributeTranslation->setAttribute($this); + } + return $this; + } + + /** + * @param AttributeTranslationInterface $attributeTranslation + * + * @return $this + */ + public function removeAttributeTranslation(AttributeTranslationInterface $attributeTranslation): self + { + if ($this->getAttributeTranslations()->contains($attributeTranslation)) { + $this->getAttributeTranslations()->removeElement($attributeTranslation); + } + return $this; + } + + public function isString(): bool + { + return $this->getType() === AttributeInterface::STRING_T; + } + + public function isDate(): bool + { + return $this->getType() === AttributeInterface::DATE_T; + } + + public function isDateTime(): bool + { + return $this->getType() === AttributeInterface::DATETIME_T; + } + + public function isBoolean(): bool + { + return $this->getType() === AttributeInterface::BOOLEAN_T; + } + + public function isInteger(): bool + { + return $this->getType() === AttributeInterface::INTEGER_T; + } + + public function isDecimal(): bool + { + return $this->getType() === AttributeInterface::DECIMAL_T; + } + + public function isPercent(): bool + { + return $this->getType() === AttributeInterface::PERCENT_T; + } + + public function isEmail(): bool + { + return $this->getType() === AttributeInterface::EMAIL_T; + } + + public function isColor(): bool + { + return $this->getType() === AttributeInterface::COLOUR_T; + } + + public function isColour(): bool + { + return $this->isColor(); + } + + public function isEnum(): bool + { + return $this->getType() === AttributeInterface::ENUM_T; + } + + public function isCountry(): bool + { + return $this->getType() === AttributeInterface::COUNTRY_T; + } +} diff --git a/lib/RoadizCoreBundle/src/Model/AttributeTranslationInterface.php b/lib/RoadizCoreBundle/src/Model/AttributeTranslationInterface.php new file mode 100644 index 00000000..c96ce5ba --- /dev/null +++ b/lib/RoadizCoreBundle/src/Model/AttributeTranslationInterface.php @@ -0,0 +1,60 @@ +label; + } + + /** + * @param string|null $label + * + * @return mixed + */ + public function setLabel(?string $label) + { + $this->label = null !== $label ? trim($label) : null; + return $this; + } + + /** + * @param TranslationInterface $translation + * + * @return mixed + */ + public function setTranslation(TranslationInterface $translation) + { + $this->translation = $translation; + return $this; + } + + /** + * @return TranslationInterface|null + */ + public function getTranslation(): ?TranslationInterface + { + return $this->translation; + } + + /** + * @return AttributeInterface + */ + public function getAttribute(): AttributeInterface + { + return $this->attribute; + } + + /** + * @param AttributeInterface $attribute + * + * @return mixed + */ + public function setAttribute(AttributeInterface $attribute) + { + $this->attribute = $attribute; + return $this; + } + + + /** + * @return array|null + */ + public function getOptions(): ?array + { + return $this->options; + } + + /** + * @param array|null $options + * + * @return $this + */ + public function setOptions(?array $options) + { + $this->options = $options; + return $this; + } +} diff --git a/lib/RoadizCoreBundle/src/Model/AttributeValueInterface.php b/lib/RoadizCoreBundle/src/Model/AttributeValueInterface.php new file mode 100644 index 00000000..40567088 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Model/AttributeValueInterface.php @@ -0,0 +1,61 @@ + + */ + public function getAttributeValueTranslations(): Collection; + + /** + * @param TranslationInterface $translation + * + * @return AttributeValueTranslationInterface + */ + public function getAttributeValueTranslation(TranslationInterface $translation): ?AttributeValueTranslationInterface; + + /** + * @param Collection $attributeValueTranslations + * + * @return mixed + */ + public function setAttributeValueTranslations(Collection $attributeValueTranslations); + + /** + * @return AttributableInterface|null + */ + public function getAttributable(): ?AttributableInterface; + + /** + * @param AttributableInterface|null $attributable + * + * @return mixed + */ + public function setAttributable(?AttributableInterface $attributable); +} diff --git a/lib/RoadizCoreBundle/src/Model/AttributeValueTrait.php b/lib/RoadizCoreBundle/src/Model/AttributeValueTrait.php new file mode 100644 index 00000000..8be3f542 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Model/AttributeValueTrait.php @@ -0,0 +1,118 @@ + "exact", + "attribute.code" => "exact", + "attribute.type" => "exact", + "attribute.group" => "exact", + "attribute.group.canonicalName" => "exact", + ]), + ApiFilter(BaseFilter\BooleanFilter::class, properties: [ + "attribute.visible", + "attribute.searchable" + ]) + ] + protected ?AttributeInterface $attribute = null; + + /** + * @var Collection + */ + #[ + ORM\OneToMany( + mappedBy: "attributeValue", + targetEntity: AttributeValueTranslationInterface::class, + cascade: ["persist", "remove"], + fetch: "EAGER", + orphanRemoval: true + ), + Serializer\Groups(["attribute", "node", "nodes_sources"]), + Serializer\Type("ArrayCollection"), + Serializer\Accessor(getter: "getAttributeValueTranslations", setter: "setAttributeValueTranslations") + ] + protected Collection $attributeValueTranslations; + + /** + * @return AttributeInterface + */ + public function getAttribute(): ?AttributeInterface + { + return $this->attribute; + } + + /** + * @param AttributeInterface $attribute + * + * @return mixed + */ + public function setAttribute(AttributeInterface $attribute) + { + $this->attribute = $attribute; + return $this; + } + + /** + * @return int + */ + public function getType(): int + { + return $this->getAttribute()->getType(); + } + + /** + * @return Collection + */ + public function getAttributeValueTranslations(): Collection + { + return $this->attributeValueTranslations; + } + + /** + * @param Collection $attributeValueTranslations + * + * @return mixed + */ + public function setAttributeValueTranslations(Collection $attributeValueTranslations) + { + $this->attributeValueTranslations = $attributeValueTranslations; + /** @var AttributeValueTranslationInterface $attributeValueTranslation */ + foreach ($this->attributeValueTranslations as $attributeValueTranslation) { + $attributeValueTranslation->setAttributeValue($this); + } + return true; + } + + /** + * @param TranslationInterface $translation + * + * @return AttributeValueTranslationInterface + */ + public function getAttributeValueTranslation(TranslationInterface $translation): ?AttributeValueTranslationInterface + { + return $this->getAttributeValueTranslations() + ->filter(function (AttributeValueTranslationInterface $attributeValueTranslation) use ($translation) { + if ($attributeValueTranslation->getTranslation() === $translation) { + return true; + } + return false; + }) + ->first() ?: null; + } +} diff --git a/lib/RoadizCoreBundle/src/Model/AttributeValueTranslationInterface.php b/lib/RoadizCoreBundle/src/Model/AttributeValueTranslationInterface.php new file mode 100644 index 00000000..c0ad7e74 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Model/AttributeValueTranslationInterface.php @@ -0,0 +1,52 @@ +value) { + return null; + } + switch ($this->getAttributeValue()->getType()) { + case AttributeInterface::DECIMAL_T: + return (float) $this->value; + case AttributeInterface::INTEGER_T: + return (int) $this->value; + case AttributeInterface::BOOLEAN_T: + return (bool) $this->value; + case AttributeInterface::DATETIME_T: + case AttributeInterface::DATE_T: + return $this->value ? new \DateTime($this->value) : null; + default: + return $this->value; + } + } + + /** + * @param mixed|null $value + * + * @return static + */ + public function setValue($value) + { + if (null === $value) { + $this->value = null; + } + switch ($this->getAttributeValue()->getType()) { + case AttributeInterface::EMAIL_T: + if (false === filter_var($value, FILTER_VALIDATE_EMAIL)) { + throw new \InvalidArgumentException('Email is not valid'); + } + $this->value = (string) $value; + return $this; + case AttributeInterface::DATETIME_T: + case AttributeInterface::DATE_T: + if ($value instanceof \DateTime) { + $this->value = $value->format('Y-m-d H:i:s'); + } else { + $this->value = (string) $value; + } + return $this; + default: + $this->value = (string) $value; + return $this; + } + } + + /** + * @param TranslationInterface $translation + * + * @return static + */ + public function setTranslation(TranslationInterface $translation) + { + $this->translation = $translation; + return $this; + } + + /** + * @return TranslationInterface|null + */ + public function getTranslation(): ?TranslationInterface + { + return $this->translation; + } + + /** + * @return AttributeValueInterface + */ + public function getAttributeValue(): AttributeValueInterface + { + return $this->attributeValue; + } + + /** + * @param AttributeValueInterface $attributeValue + * + * @return static + */ + public function setAttributeValue(AttributeValueInterface $attributeValue) + { + $this->attributeValue = $attributeValue; + return $this; + } + + /** + * @return AttributeInterface + */ + public function getAttribute(): ?AttributeInterface + { + return $this->getAttributeValue()->getAttribute(); + } +} diff --git a/lib/RoadizCoreBundle/src/Model/RealmInterface.php b/lib/RoadizCoreBundle/src/Model/RealmInterface.php new file mode 100644 index 00000000..e637d108 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Model/RealmInterface.php @@ -0,0 +1,51 @@ +objectManager = $objectManager; + $this->originalNode = $originalNode; + $this->nodeNamePolicy = $nodeNamePolicy; + } + + /** + * Warning this method flush entityManager at its end. + * + * @return Node + */ + public function duplicate(): Node + { + $this->objectManager->refresh($this->originalNode); + + if ($this->originalNode->isLocked()) { + throw new \RuntimeException('Locked node cannot be duplicated.'); + } + + $parent = $this->originalNode->getParent(); + $node = clone $this->originalNode; + + if ($this->objectManager->contains($node)) { + $this->objectManager->clear(); + } + + if ($parent !== null) { + /** @var Node $parent */ + $parent = $this->objectManager->find(Node::class, $parent->getId()); + $node->setParent($parent); + } + // Demote cloned node to draft + $node->setStatus(Node::DRAFT); + + $node = $this->doDuplicate($node); + $this->objectManager->flush(); + $this->objectManager->refresh($node); + + return $node; + } + + /** + * Warning, do not do any FLUSH here to preserve transactional integrity. + * + * @param Node $node + * @return Node + */ + private function doDuplicate(Node &$node): Node + { + $node->setNodeName( + $this->nodeNamePolicy->getSafeNodeName($node->getNodeSources()->first()) + ); + + /** @var Node $child */ + foreach ($node->getChildren() as $child) { + $child->setParent($node); + $this->doDuplicate($child); + } + + /** @var NodesSources $nodeSource */ + foreach ($node->getNodeSources() as $nodeSource) { + $this->objectManager->persist($nodeSource); + + /** @var NodesSourcesDocuments $nsDoc */ + foreach ($nodeSource->getDocumentsByFields() as $nsDoc) { + $nsDoc->setNodeSource($nodeSource); + $doc = $nsDoc->getDocument(); + $nsDoc->setDocument($doc); + $f = $nsDoc->getField(); + $nsDoc->setField($f); + $this->objectManager->persist($nsDoc); + } + } + + /* + * Duplicate Node to Node relationship + */ + $this->doDuplicateNodeRelations($node); + /* + * Duplicate Node attributes values + */ + /** @var AttributeValue $attributeValue */ + foreach ($node->getAttributeValues() as $attributeValue) { + $this->objectManager->persist($attributeValue); + foreach ($attributeValue->getAttributeValueTranslations() as $attributeValueTranslation) { + $this->objectManager->persist($attributeValueTranslation); + } + } + + /* + * Persist duplicated node + */ + $this->objectManager->persist($node); + + return $node; + } + + /** + * Duplicate Node to Node relationship. + * + * Warning, do not do any FLUSH here to preserve transactional integrity. + * + * @param Node $node + * @return Node + */ + private function doDuplicateNodeRelations(Node $node): Node + { + $nodeRelations = new ArrayCollection($node->getBNodes()->toArray()); + foreach ($nodeRelations as $position => $nodeRelation) { + $ntn = new NodesToNodes($node, $nodeRelation->getNodeB(), $nodeRelation->getField()); + $ntn->setPosition($position); + $this->objectManager->persist($ntn); + } + + return $node; + } +} diff --git a/lib/RoadizCoreBundle/src/Node/NodeFactory.php b/lib/RoadizCoreBundle/src/Node/NodeFactory.php new file mode 100644 index 00000000..d9157b35 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Node/NodeFactory.php @@ -0,0 +1,113 @@ +nodeNamePolicy = $nodeNamePolicy; + $this->managerRegistry = $managerRegistry; + } + + public function create( + string $title, + ?NodeTypeInterface $type = null, + ?TranslationInterface $translation = null, + ?Node $node = null, + ?Node $parent = null + ): Node { + /** @var NodeRepository $repository */ + $repository = $this->managerRegistry->getRepository(Node::class) + ->setDisplayingAllNodesStatuses(true); + + if ($node === null && $type === null) { + throw new \RuntimeException('Cannot create node from null NodeType and null Node.'); + } + + if ($translation === null) { + $translation = $this->managerRegistry->getRepository(Translation::class)->findDefault(); + } + + if ($node === null) { + $node = new Node($type); + } + + if ($type instanceof NodeType) { + $node->setTtl($type->getDefaultTtl()); + } + if (null !== $parent) { + $node->setParent($parent); + } + + $sourceClass = $node->getNodeType()->getSourceEntityFullQualifiedClassName(); + /** @var NodesSources $source */ + $source = new $sourceClass($node, $translation); + $manager = $this->managerRegistry->getManagerForClass(NodesSources::class); + $source->injectObjectManager($manager); + $source->setTitle($title); + $source->setPublishedAt(new \DateTime()); + + /* + * Name node against policy + */ + $nodeName = $this->nodeNamePolicy->getCanonicalNodeName($source); + if (empty($nodeName)) { + throw new \RuntimeException('Node name is empty.'); + } + if (true === $repository->exists($nodeName)) { + $nodeName = $this->nodeNamePolicy->getSafeNodeName($source); + } + if (mb_strlen($nodeName) > 250) { + throw new \InvalidArgumentException(sprintf('Node name "%s" is too long.', $nodeName)); + } + $node->setNodeName($nodeName); + + $manager->persist($source); + $manager->persist($node); + + return $node; + } + + public function createWithUrlAlias( + string $urlAlias, + string $title, + ?NodeTypeInterface $type = null, + ?TranslationInterface $translation = null, + ?Node $node = null, + ?Node $parent = null + ): Node { + $node = $this->create($title, $type, $translation, $node, $parent); + /** @var UrlAliasRepository $repository */ + $repository = $this->managerRegistry->getRepository(UrlAlias::class); + if (false === $repository->exists($urlAlias)) { + $alias = new UrlAlias($node->getNodeSources()->first()); + $alias->setAlias($urlAlias); + $this->managerRegistry->getManagerForClass(UrlAlias::class)->persist($alias); + } + + return $node; + } +} diff --git a/lib/RoadizCoreBundle/src/Node/NodeMover.php b/lib/RoadizCoreBundle/src/Node/NodeMover.php new file mode 100644 index 00000000..2e866db9 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Node/NodeMover.php @@ -0,0 +1,226 @@ +urlGenerator = $urlGenerator; + $this->logger = $logger ?? new NullLogger(); + $this->dispatcher = $dispatcher; + $this->handlerFactory = $handlerFactory; + $this->managerRegistry = $managerRegistry; + $this->cacheAdapter = $cacheAdapter; + } + + private function getManager(): ObjectManager + { + $manager = $this->managerRegistry->getManagerForClass(Redirection::class); + if (null === $manager) { + throw new \RuntimeException('No manager was found during transtyping.'); + } + return $manager; + } + + /** + * Warning: this method DOES NOT flush entity manager. + * + * @param Node $node + * @param Node|null $parentNode + * @param float $position + * @param bool $force + * @param bool $cleanPosition + * + * @return Node + */ + public function move( + Node $node, + ?Node $parentNode, + float $position, + bool $force = false, + bool $cleanPosition = true + ): Node { + if ($node->isLocked() && $force === false) { + throw new BadRequestHttpException('Locked node cannot be moved.'); + } + + if ($node->getParent() !== $parentNode) { + $node->setParent($parentNode); + } + + $node->setPosition($position); + + if ($cleanPosition) { + $this->getManager()->flush(); + /** @var NodeHandler $nodeHandler */ + $nodeHandler = $this->handlerFactory->getHandler($node); + $nodeHandler->setNode($node); + $nodeHandler->cleanPositions(); + } + + if ($this->cacheAdapter instanceof ResettableInterface) { + $this->cacheAdapter->reset(); + } + + return $node; + } + + /** + * @param Node $node + * + * @return array + */ + public function getNodeSourcesUrls(Node $node): array + { + $paths = []; + $lastUrl = null; + /** @var NodesSources $nodeSource */ + foreach ($node->getNodeSources() as $nodeSource) { + $url = $this->urlGenerator->generate( + RouteObjectInterface::OBJECT_BASED_ROUTE_NAME, + [ + RouteObjectInterface::ROUTE_OBJECT => $nodeSource, + ] + ); + if (null !== $lastUrl && $url === $lastUrl) { + throw new SameNodeUrlException('NodeSource URL are the same between translations.'); + } + $paths[$nodeSource->getTranslation()->getLocale()] = $url; + $this->logger->debug( + 'Redirect ' . $nodeSource->getId() . ' ' . $nodeSource->getTranslation()->getLocale() . ': ' . $url + ); + $lastUrl = $url; + } + return $paths; + } + + /** + * @param Node $node + * @param array $previousPaths + * @param bool $permanently + */ + public function redirectAll(Node $node, array $previousPaths, bool $permanently = true): void + { + if (count($previousPaths) > 0) { + /** @var NodesSources $nodeSource */ + foreach ($node->getNodeSources() as $nodeSource) { + if (!empty($previousPaths[$nodeSource->getTranslation()->getLocale()])) { + $this->redirect( + $nodeSource, + $previousPaths[$nodeSource->getTranslation()->getLocale()], + $permanently + ); + } + } + } + } + + /** + * Warning: this method DOES NOT flush entity manager. + * + * @param NodesSources $nodeSource + * @param string $previousPath + * @param bool $permanently + * + * @return NodesSources + */ + protected function redirect(NodesSources $nodeSource, string $previousPath, bool $permanently = true): NodesSources + { + if (empty($previousPath) || $previousPath === '/') { + $this->logger->warning('Cannot redirect empty or root path: ' . $nodeSource->getTitle()); + return $nodeSource; + } + + $newPath = $this->urlGenerator->generate( + RouteObjectInterface::OBJECT_BASED_ROUTE_NAME, + [ + RouteObjectInterface::ROUTE_OBJECT => $nodeSource, + NodeRouter::NO_CACHE_PARAMETER => true // do not use nodeSourceUrl cache provider + ] + ); + + /* + * Only creates redirection if path changed + */ + if ($previousPath !== $newPath) { + /** @var EntityRepository $redirectionRepo */ + $redirectionRepo = $this->managerRegistry->getRepository(Redirection::class); + + /* + * Checks if new node path is already registered as + * a redirection --> remove redirection. + */ + $loopingRedirection = $redirectionRepo->findOneBy([ + 'query' => $newPath, + ]); + if (null !== $loopingRedirection) { + $this->getManager()->remove($loopingRedirection); + } + + $existingRedirection = $redirectionRepo->findOneBy([ + 'query' => $previousPath, + ]); + if (null === $existingRedirection) { + $existingRedirection = new Redirection(); + $this->getManager()->persist($existingRedirection); + $existingRedirection->setQuery($previousPath); + $this->logger->info('New redirection created', [ + 'oldPath' => $previousPath, + 'nodeSource' => $nodeSource->getId(), + ]); + } + $existingRedirection->setRedirectNodeSource($nodeSource); + if ($permanently) { + $existingRedirection->setType(Response::HTTP_MOVED_PERMANENTLY); + } else { + $existingRedirection->setType(Response::HTTP_FOUND); + } + } + + return $nodeSource; + } +} diff --git a/lib/RoadizCoreBundle/src/Node/NodeNameChecker.php b/lib/RoadizCoreBundle/src/Node/NodeNameChecker.php new file mode 100644 index 00000000..6dd60efe --- /dev/null +++ b/lib/RoadizCoreBundle/src/Node/NodeNameChecker.php @@ -0,0 +1,129 @@ +useTypedSuffix = $useTypedSuffix; + $this->managerRegistry = $managerRegistry; + } + + public function getCanonicalNodeName(NodesSources $nodeSource): string + { + if ($nodeSource->getTitle() !== '') { + if ($nodeSource->isReachable() || !$this->useTypedSuffix) { + return StringHandler::slugify($nodeSource->getTitle()); + } + return sprintf( + '%s-%s', + StringHandler::slugify($nodeSource->getTitle()), + StringHandler::slugify($nodeSource->getNodeTypeName()), + ); + } + return sprintf( + '%s-%s', + StringHandler::slugify($nodeSource->getNodeTypeName()), + null !== $nodeSource->getNode() ? $nodeSource->getNode()->getId() : $nodeSource->getId() + ); + } + + public function getSafeNodeName(NodesSources $nodeSource): string + { + return sprintf( + '%s-%s', + $this->getCanonicalNodeName($nodeSource), + uniqid() + ); + } + + public function getDatestampedNodeName(NodesSources $nodeSource): string + { + return sprintf( + '%s-%s', + $this->getCanonicalNodeName($nodeSource), + $nodeSource->getPublishedAt()->format('Y-m-d') + ); + } + + /** + * Test if current node name is suffixed with a 13 chars Unique ID (uniqid()). + * + * @param string $canonicalNodeName Node name without uniqid after. + * @param string $nodeName Node name to test + * @return bool + */ + public function isNodeNameWithUniqId(string $canonicalNodeName, string $nodeName): bool + { + $pattern = '#^' . preg_quote($canonicalNodeName) . '\-[0-9a-z]{13}$#'; + $returnState = preg_match_all($pattern, $nodeName); + + if (1 === $returnState) { + return true; + } + + return false; + } + + /** + * @param string $nodeName + * + * @return bool + */ + public function isNodeNameValid(string $nodeName): bool + { + if (preg_match('#^[a-zA-Z0-9\-]+$#', $nodeName) === 1) { + return true; + } + return false; + } + + /** + * Test if node’s name is already used as a name or an url-alias. + * + * @param string $nodeName + * + * @return bool + * @throws \Doctrine\ORM\NoResultException + * @throws \Doctrine\ORM\NonUniqueResultException + */ + public function isNodeNameAlreadyUsed(string $nodeName): bool + { + $nodeName = StringHandler::slugify($nodeName); + /** @var UrlAliasRepository $urlAliasRepo */ + $urlAliasRepo = $this->managerRegistry->getRepository(UrlAlias::class); + /** @var NodeRepository $nodeRepo */ + $nodeRepo = $this->managerRegistry + ->getRepository(Node::class) + ->setDisplayingNotPublishedNodes(true); + + if ( + false === (bool) $urlAliasRepo->exists($nodeName) && + false === (bool) $nodeRepo->exists($nodeName) + ) { + return false; + } + return true; + } +} diff --git a/lib/RoadizCoreBundle/src/Node/NodeNamePolicyFactory.php b/lib/RoadizCoreBundle/src/Node/NodeNamePolicyFactory.php new file mode 100644 index 00000000..20c78008 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Node/NodeNamePolicyFactory.php @@ -0,0 +1,31 @@ +registry = $registry; + $this->useTypedNodeNames = $useTypedNodeNames; + } + + public function create(): NodeNamePolicyInterface + { + return new NodeNameChecker( + $this->registry, + $this->useTypedNodeNames + ); + } +} diff --git a/lib/RoadizCoreBundle/src/Node/NodeNamePolicyInterface.php b/lib/RoadizCoreBundle/src/Node/NodeNamePolicyInterface.php new file mode 100644 index 00000000..0fea5304 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Node/NodeNamePolicyInterface.php @@ -0,0 +1,34 @@ +managerRegistry = $managerRegistry; + $this->dispatcher = $dispatcher; + } + + public function translateNode( + ?Translation $sourceTranslation, + Translation $destinationTranslation, + Node $node, + bool $translateChildren = false + ): Node { + $this->translateSingleNode($sourceTranslation, $destinationTranslation, $node); + + if ($translateChildren) { + /** @var Node $child */ + foreach ($node->getChildren() as $child) { + $this->translateNode($sourceTranslation, $destinationTranslation, $child, $translateChildren); + } + } + + return $node; + } + + private function translateSingleNode( + ?Translation $sourceTranslation, + Translation $destinationTranslation, + Node $node + ): NodesSources { + /** @var NodesSources|null $existing */ + $existing = $this->managerRegistry + ->getRepository(NodesSources::class) + ->setDisplayingAllNodesStatuses(true) + ->setDisplayingNotPublishedNodes(true) + ->findOneByNodeAndTranslation($node, $destinationTranslation); + + if (null === $existing) { + /** @var NodesSources|false $baseSource */ + $baseSource = + $node->getNodeSourcesByTranslation($sourceTranslation)->first() ?: + $node->getNodeSources()->filter(function (NodesSources $nodesSources) { + return $nodesSources->getTranslation()->isDefaultTranslation(); + })->first() ?: + $node->getNodeSources()->first(); + + if (!($baseSource instanceof NodesSources)) { + throw new \RuntimeException('Cannot translate a Node without any NodesSources'); + } + $source = clone $baseSource; + $this->managerRegistry->getManager()->persist($source); + + foreach ($source->getDocumentsByFields() as $document) { + $this->managerRegistry->getManager()->persist($document); + } + $source->setTranslation($destinationTranslation); + $source->setNode($node); + + /* + * Dispatch event + */ + $this->dispatcher->dispatch(new NodesSourcesCreatedEvent($source)); + + return $source; + } else { + return $existing; + } + } +} diff --git a/lib/RoadizCoreBundle/src/Node/NodeTranstyper.php b/lib/RoadizCoreBundle/src/Node/NodeTranstyper.php new file mode 100644 index 00000000..da9d545e --- /dev/null +++ b/lib/RoadizCoreBundle/src/Node/NodeTranstyper.php @@ -0,0 +1,306 @@ +logger = $logger ?? new NullLogger(); + $this->managerRegistry = $managerRegistry; + } + + private function getManager(): ObjectManager + { + $manager = $this->managerRegistry->getManagerForClass(NodesSources::class); + if (null === $manager) { + throw new \RuntimeException('No manager was found during trans-typing.'); + } + return $manager; + } + + /** + * @param NodeTypeFieldInterface $oldField + * @param NodeTypeInterface $destinationNodeType + * + * @return NodeTypeField|null + */ + private function getMatchingNodeTypeField( + NodeTypeFieldInterface $oldField, + NodeTypeInterface $destinationNodeType + ): ?NodeTypeFieldInterface { + $criteria = Criteria::create(); + $criteria->andWhere(Criteria::expr()->eq('name', $oldField->getName())) + ->andWhere(Criteria::expr()->eq('type', $oldField->getType())) + ->setMaxResults(1); + $field = $destinationNodeType->getFields()->matching($criteria)->first(); + return $field ? $field : null; + } + + /** + * Warning, this method DOES NOT flush entityManager at the end. + * + * Trans-typing SHOULD be executed in one single transaction + * @see https://www.doctrine-project.org/projects/doctrine-orm/en/latest/reference/transactions-and-concurrency.html + * + * @param Node $node + * @param NodeTypeInterface $destinationNodeType + * @param bool $mock + * + * @return Node + */ + public function transtype(Node $node, NodeTypeInterface $destinationNodeType, bool $mock = true): Node + { + /* + * Get an association between old fields and new fields + * to find data that can be transferred during trans-typing. + */ + $fieldAssociations = []; + $oldFields = $node->getNodeType()->getFields(); + + foreach ($oldFields as $oldField) { + $matchingField = $this->getMatchingNodeTypeField($oldField, $destinationNodeType); + if (null !== $matchingField) { + $fieldAssociations[] = [ + $oldField, // old type field + $matchingField, // new type field + ]; + } + } + $this->logger->debug('Get matching fields'); + + $sourceClass = $destinationNodeType->getSourceEntityFullQualifiedClassName(); + + /* + * Testing if new nodeSource class is available + * and cache have been cleared before actually performing + * trans-type, not to get an orphan node. + */ + if ($mock) { + $this->mockTranstype($destinationNodeType); + } + + /* + * Perform actual trans-typing + */ + $existingSources = $node->getNodeSources()->toArray(); + $existingLogs = []; + /** @var NodesSources $existingSource */ + foreach ($existingSources as $existingSource) { + $existingLogs[$existingSource->getTranslation()->getLocale()] = array_map(function (Log $log) { + $this->managerRegistry->getManager()->detach($log); + return $log; + }, $existingSource->getLogs()->toArray()); + } + $existingRedirections = []; + /** @var NodesSources $existingSource */ + foreach ($existingSources as $existingSource) { + $existingRedirections[$existingSource->getTranslation()->getLocale()] = array_map(function (Redirection $redirection) { + $this->managerRegistry->getManager()->detach($redirection); + return $redirection; + }, $existingSource->getRedirections()->toArray()); + } + + $this->removeOldSources($node, $existingSources); + + /** @var NodesSources $existingSource */ + foreach ($existingSources as $existingSource) { + $this->managerRegistry->getManager()->detach($existingSource); + $this->doTranstypeSingleSource( + $node, + $existingSource, + $existingSource->getTranslation(), + $sourceClass, + $fieldAssociations, + $existingLogs, + $existingRedirections + ); + $this->logger->debug('Transtyped: ' . $existingSource->getTranslation()->getLocale()); + } + + $node->setNodeType($destinationNodeType); + return $node; + } + + /** + * @param Node $node + * @param array $sources + */ + protected function removeOldSources(Node $node, array &$sources): void + { + /** @var NodesSources $existingSource */ + foreach ($sources as $existingSource) { + // First plan old source deletion. + $node->removeNodeSources($existingSource); + $this->getManager()->remove($existingSource); + } + // Flush once + $this->getManager()->flush(); + $this->logger->debug('Removed old sources'); + } + + /** + * Warning, this method DO NOT flush entityManager at the end. + * + * @param Node $node + * @param NodesSources $existingSource + * @param TranslationInterface $translation + * @param string $sourceClass + * @param array $fieldAssociations + * @param array $existingLogs + * @param array $existingRedirections + * @return NodesSources + */ + protected function doTranstypeSingleSource( + Node $node, + NodesSources $existingSource, + TranslationInterface $translation, + string $sourceClass, + array &$fieldAssociations, + array &$existingLogs, + array &$existingRedirections + ): NodesSources { + /** @var NodesSources $source */ + $source = new $sourceClass($node, $translation); + $this->getManager()->persist($source); + $source->setTitle($existingSource->getTitle()); + + foreach ($fieldAssociations as $fields) { + /** @var NodeTypeField $oldField */ + $oldField = $fields[0]; + /** @var NodeTypeField $matchingField */ + $matchingField = $fields[1]; + + if (!$oldField->isVirtual()) { + /* + * Copy simple data from source to another + */ + $setter = $oldField->getSetterName(); + $getter = $oldField->getGetterName(); + $source->$setter($existingSource->$getter()); + } elseif ($oldField->getType() === AbstractField::DOCUMENTS_T) { + /* + * Copy documents. + */ + $documents = $existingSource->getDocumentsByFieldsWithName($oldField->getName()); + foreach ($documents as $document) { + $nsDoc = new NodesSourcesDocuments($source, $document, $matchingField); + $this->getManager()->persist($nsDoc); + $source->getDocumentsByFields()->add($nsDoc); + } + } + } + $this->logger->debug('Fill existing data'); + + + /** @var Log $log */ + foreach ($existingLogs[$translation->getLocale()] as $log) { + $newLog = clone $log; + $newLog->setAdditionalData($log->getAdditionalData()); + $newLog->setChannel($log->getChannel()); + $newLog->setClientIp($log->getClientIp()); + $newLog->setUser($log->getUser()); + $newLog->setUsername($log->getUsername()); + $this->getManager()->persist($newLog); + $newLog->setNodeSource($source); + } + $this->logger->debug('Recreate logs'); + + /* + * Recreate url-aliases too. + */ + /** @var UrlAlias $urlAlias */ + foreach ($existingSource->getUrlAliases() as $urlAlias) { + $newUrlAlias = new UrlAlias($source); + $this->getManager()->persist($newUrlAlias); + $newUrlAlias->setAlias($urlAlias->getAlias()); + $source->addUrlAlias($newUrlAlias); + } + $this->logger->debug('Recreate aliases'); + + /* + * Recreate redirections too. + */ + /** @var Redirection $existingRedirection */ + foreach ($existingRedirections[$translation->getLocale()] as $existingRedirection) { + $newRedirection = new Redirection(); + $this->getManager()->persist($newRedirection); + $newRedirection->setRedirectNodeSource($source); + $newRedirection->setQuery($existingRedirection->getQuery()); + $newRedirection->setType($existingRedirection->getType()); + } + $this->logger->debug('Recreate aliases'); + + return $source; + } + + /** + * Warning, this method flushes entityManager. + * + * @param NodeTypeInterface $nodeType + * @throws \InvalidArgumentException If mock fails due to Source class not existing. + */ + protected function mockTranstype(NodeTypeInterface $nodeType): void + { + $sourceClass = $nodeType->getSourceEntityFullQualifiedClassName(); + if (!class_exists($sourceClass)) { + throw new \InvalidArgumentException($sourceClass . ' node-source class does not exist.'); + } + $uniqueId = uniqid(); + /* + * Testing if new nodeSource class is available + * and cache have been cleared before actually performing + * transtype, not to get an orphan node. + */ + $node = new Node(); + $node->setNodeName('testing_before_transtype' . $uniqueId); + $this->getManager()->persist($node); + + $translation = new Translation(); + $translation->setAvailable(true); + $translation->setLocale(substr($uniqueId, 0, 10)); + $translation->setName('test' . $uniqueId); + $this->getManager()->persist($translation); + + /** @var NodesSources $testSource */ + $testSource = new $sourceClass($node, $translation); + $testSource->setTitle('testing_before_transtype' . $uniqueId); + $this->getManager()->persist($testSource); + $this->getManager()->flush(); + + // then remove it if OK + $this->getManager()->remove($testSource); + $this->getManager()->remove($node); + $this->getManager()->remove($translation); + $this->getManager()->flush(); + } +} diff --git a/lib/RoadizCoreBundle/src/Node/UniqueNodeGenerator.php b/lib/RoadizCoreBundle/src/Node/UniqueNodeGenerator.php new file mode 100644 index 00000000..0a2f52c5 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Node/UniqueNodeGenerator.php @@ -0,0 +1,162 @@ +nodeNamePolicy = $nodeNamePolicy; + $this->managerRegistry = $managerRegistry; + } + + /** + * Generate a node with a unique name. + * + * This method flush entity-manager. + * + * @param NodeType $nodeType + * @param Translation $translation + * @param Node|null $parent + * @param Tag|null $tag + * @param bool $pushToTop + * @return NodesSources + */ + public function generate( + NodeType $nodeType, + Translation $translation, + Node $parent = null, + Tag $tag = null, + bool $pushToTop = false + ): NodesSources { + $name = $nodeType->getDisplayName() . " " . uniqid(); + $node = new Node($nodeType); + $node->setTtl($nodeType->getDefaultTtl()); + + if (null !== $tag) { + $node->addTag($tag); + } + if (null !== $parent) { + $parent->addChild($node); + } + + if ($pushToTop) { + /* + * Force position before first item + */ + $node->setPosition(0.5); + } + + $sourceClass = NodeType::getGeneratedEntitiesNamespace() . "\\" . $nodeType->getSourceEntityClassName(); + + /** @var NodesSources $source */ + $source = new $sourceClass($node, $translation); + $source->setTitle($name); + $source->setPublishedAt(new \DateTime()); + $node->setNodeName($this->nodeNamePolicy->getCanonicalNodeName($source)); + + $manager = $this->managerRegistry->getManagerForClass(Node::class); + if (null !== $manager) { + $manager->persist($node); + $manager->persist($source); + $manager->flush(); + } + + return $source; + } + + /** + * Try to generate a unique node from request variables. + * + * @param Request $request + * + * @return NodesSources + * @throws \Doctrine\ORM\ORMException + * @throws \Doctrine\ORM\OptimisticLockException + */ + public function generateFromRequest(Request $request) + { + $pushToTop = false; + + if ($request->get('pushTop') == 1) { + $pushToTop = true; + } + + if ($request->get('tagId') > 0) { + $tag = $this->managerRegistry + ->getRepository(Tag::class) + ->find((int) $request->get('tagId')); + } else { + $tag = null; + } + + if ($request->get('parentNodeId') > 0) { + $parent = $this->managerRegistry + ->getRepository(Node::class) + ->find((int) $request->get('parentNodeId')); + } else { + $parent = null; + } + + if ($request->get('nodeTypeId') > 0) { + /** @var NodeType|null $nodeType */ + $nodeType = $this->managerRegistry + ->getRepository(NodeType::class) + ->find((int) $request->get('nodeTypeId')); + + if (null !== $nodeType) { + if ($request->get('translationId') > 0) { + /** @var Translation $translation */ + $translation = $this->managerRegistry + ->getRepository(Translation::class) + ->find((int) $request->get('translationId')); + } else { + /* + * If parent has only on translation, use parent translation instead of default one. + */ + if (null !== $parent && $parent->getNodeSources()->count() === 1) { + $translation = $parent->getNodeSources()->first()->getTranslation(); + } else { + /** @var Translation $translation */ + $translation = $this->managerRegistry + ->getRepository(Translation::class) + ->findDefault(); + } + } + + return $this->generate( + $nodeType, + $translation, + $parent, + $tag, + $pushToTop + ); + } else { + throw new BadRequestHttpException("Node-type does not exist."); + } + } else { + throw new BadRequestHttpException("No node-type ID has been given."); + } + } +} diff --git a/lib/RoadizCoreBundle/src/Node/UniversalDataDuplicator.php b/lib/RoadizCoreBundle/src/Node/UniversalDataDuplicator.php new file mode 100644 index 00000000..41b0ae45 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Node/UniversalDataDuplicator.php @@ -0,0 +1,171 @@ +managerRegistry = $managerRegistry; + } + + /** + * Duplicate node-source universal to any other language source for the same node. + * + * **Be careful, this method does not flush.** + * + * @param NodesSources $source + * @return bool + * @throws \Doctrine\ORM\NoResultException + * @throws \Doctrine\ORM\NonUniqueResultException + * @throws \Doctrine\ORM\ORMException + */ + public function duplicateUniversalContents(NodesSources $source): bool + { + /* + * Only if source is default translation. + * Non-default translation source should not contain universal fields. + */ + if ($source->getTranslation()->isDefaultTranslation() || !$this->hasDefaultTranslation($source)) { + $nodeTypeFieldRepository = $this->managerRegistry->getRepository(NodeTypeField::class); + $universalFields = $nodeTypeFieldRepository->findAllUniversal($source->getNode()->getNodeType()); + + if (count($universalFields) > 0) { + $repository = $this->managerRegistry->getRepository(NodesSources::class); + $repository->setDisplayingAllNodesStatuses(true) + ->setDisplayingNotPublishedNodes(true) + ; + $otherSources = $repository->findBy([ + 'node' => $source->getNode(), + 'id' => ['!=', $source->getId()], + ]); + + /** @var NodeTypeField $universalField */ + foreach ($universalFields as $universalField) { + /** @var NodesSources $otherSource */ + foreach ($otherSources as $otherSource) { + if (!$universalField->isVirtual()) { + $this->duplicateNonVirtualField($source, $otherSource, $universalField); + } else { + switch ($universalField->getType()) { + case AbstractField::DOCUMENTS_T: + $this->duplicateDocumentsField($source, $otherSource, $universalField); + break; + case AbstractField::MULTI_PROVIDER_T: + case AbstractField::SINGLE_PROVIDER_T: + case AbstractField::MANY_TO_ONE_T: + case AbstractField::MANY_TO_MANY_T: + $this->duplicateNonVirtualField($source, $otherSource, $universalField); + break; + } + } + } + } + return true; + } + } + + return false; + } + + /** + * @param NodesSources $source + * + * @return bool + * @throws \Doctrine\ORM\NoResultException + * @throws \Doctrine\ORM\NonUniqueResultException + */ + private function hasDefaultTranslation(NodesSources $source): bool + { + /** @var TranslationRepository $translationRepository */ + $translationRepository = $this->managerRegistry->getRepository(Translation::class); + /** @var Translation $defaultTranslation */ + $defaultTranslation = $translationRepository->findDefault(); + + /** @var NodesSourcesRepository $repository */ + $repository = $this->managerRegistry->getRepository(NodesSources::class); + $sourceCount = $repository->setDisplayingAllNodesStatuses(true) + ->setDisplayingNotPublishedNodes(true) + ->countBy([ + 'node' => $source->getNode(), + 'translation' => $defaultTranslation, + ]); + + return $sourceCount === 1; + } + + /** + * @param NodesSources $universalSource + * @param NodesSources $destSource + * @param NodeTypeField $field + */ + protected function duplicateNonVirtualField( + NodesSources $universalSource, + NodesSources $destSource, + NodeTypeField $field + ): void { + $getter = $field->getGetterName(); + $setter = $field->getSetterName(); + + $destSource->$setter($universalSource->$getter()); + } + + /** + * @param NodesSources $universalSource + * @param NodesSources $destSource + * @param NodeTypeField $field + */ + protected function duplicateDocumentsField( + NodesSources $universalSource, + NodesSources $destSource, + NodeTypeField $field + ): void { + $newDocuments = $this->managerRegistry + ->getRepository(NodesSourcesDocuments::class) + ->findBy(['nodeSource' => $universalSource, 'field' => $field]); + + $formerDocuments = $this->managerRegistry + ->getRepository(NodesSourcesDocuments::class) + ->findBy(['nodeSource' => $destSource, 'field' => $field]); + + $manager = $this->managerRegistry->getManagerForClass(NodesSourcesDocuments::class); + if (null === $manager) { + return; + } + + /* Delete former documents */ + if (count($formerDocuments) > 0) { + foreach ($formerDocuments as $formerDocument) { + $manager->remove($formerDocument); + } + } + /* Add new documents */ + if (count($newDocuments) > 0) { + $position = 1; + /** @var NodesSourcesDocuments $newDocument */ + foreach ($newDocuments as $newDocument) { + $nsDoc = new NodesSourcesDocuments($destSource, $newDocument->getDocument(), $field); + $nsDoc->setPosition($position); + $position++; + + $manager->persist($nsDoc); + } + } + } +} diff --git a/lib/RoadizCoreBundle/src/NodeType/ApiResourceGenerator.php b/lib/RoadizCoreBundle/src/NodeType/ApiResourceGenerator.php new file mode 100644 index 00000000..428b6eb5 --- /dev/null +++ b/lib/RoadizCoreBundle/src/NodeType/ApiResourceGenerator.php @@ -0,0 +1,173 @@ +apiResourcesDir = $apiResourcesDir; + } + + /** + * @param NodeTypeInterface $nodeType + * @return string|null Generated resource file path or null if nothing done. + */ + public function generate(NodeTypeInterface $nodeType): ?string + { + $filesystem = new Filesystem(); + + if (!$filesystem->exists($this->apiResourcesDir)) { + throw new LogicException($this->apiResourcesDir . ' folder does not exist.'); + } + + $resourcePath = $this->getResourcePath($nodeType); + + if (!$filesystem->exists($resourcePath)) { + $filesystem->dumpFile( + $resourcePath, + Yaml::dump($this->getApiResourceDefinition($nodeType), 6) + ); + \clearstatcache(true, $resourcePath); + return $resourcePath; + } else { + return null; + } + } + + public function remove(NodeTypeInterface $nodeType): void + { + $filesystem = new Filesystem(); + + if (!$filesystem->exists($this->apiResourcesDir)) { + throw new LogicException($this->apiResourcesDir . ' folder does not exist.'); + } + + $resourcePath = $this->getResourcePath($nodeType); + + if ($filesystem->exists($resourcePath)) { + $filesystem->remove($resourcePath); + @\clearstatcache(true, $resourcePath); + } + } + + protected function getResourcePath(NodeTypeInterface $nodeType): string + { + return $this->apiResourcesDir . '/' . (new UnicodeString($nodeType->getName())) + ->lower() + ->prepend('ns') + ->append('.yml') + ->toString(); + } + + protected function getApiResourceDefinition(NodeTypeInterface $nodeType): array + { + $fqcn = (new UnicodeString($nodeType->getSourceEntityFullQualifiedClassName())) + ->trimStart('\\') + ->toString(); + + return [ $fqcn => [ + 'iri' => $nodeType->getName(), + 'shortName' => $nodeType->getName(), + 'collectionOperations' => $this->getCollectionOperations($nodeType), + 'itemOperations' => $this->getItemOperations($nodeType), + ]]; + } + + protected function getCollectionOperations(NodeTypeInterface $nodeType): array + { + if ($nodeType->isReachable()) { + $groups = [ + "nodes_sources_base", + "nodes_sources_default", + "urls", + "tag_base", + "translation_base", + "document_display", + "document_thumbnails", + "document_display_sources", + ...$this->getGroupedFieldsSerializationGroups($nodeType) + ]; + return [ + 'get' => [ + 'method' => 'GET', + 'normalization_context' => [ + 'enable_max_depth' => true, + 'groups' => array_values(array_filter(array_unique($groups))) + ], + ] + ]; + } + return []; + } + + protected function getItemOperations(NodeTypeInterface $nodeType): array + { + $groups = [ + "nodes_sources", + "urls", + "tag_base", + "translation_base", + "document_display", + "document_thumbnails", + "document_display_sources", + ...$this->getGroupedFieldsSerializationGroups($nodeType) + ]; + $operations = [ + 'get' => [ + 'method' => 'GET', + 'normalization_context' => [ + 'groups' => array_values(array_filter(array_unique($groups))) + ], + ] + ]; + + /* + * Create itemOperation for WebResponseController action + */ + if ($nodeType->isReachable()) { + $operations['getByPath'] = [ + 'method' => 'GET', + 'normalization_context' => [ + 'enable_max_depth' => true, + 'groups' => array_merge(array_values(array_filter(array_unique($groups))), [ + 'web_response', + 'walker', + 'walker_level', + 'walker_metadata', + 'meta', + 'children', + ]) + ], + ]; + } + + return $operations; + } + + protected function getGroupedFieldsSerializationGroups(NodeTypeInterface $nodeType): array + { + $groups = []; + foreach ($nodeType->getFields() as $field) { + if (null !== $field->getGroupNameCanonical()) { + $groups[] = (new UnicodeString($field->getGroupNameCanonical())) + ->lower() + ->snake() + ->prepend('nodes_sources_') + ->toString() + ; + } + } + return $groups; + } +} diff --git a/lib/RoadizCoreBundle/src/NodeType/NodeTypeResolver.php b/lib/RoadizCoreBundle/src/NodeType/NodeTypeResolver.php new file mode 100644 index 00000000..d8dfdd67 --- /dev/null +++ b/lib/RoadizCoreBundle/src/NodeType/NodeTypeResolver.php @@ -0,0 +1,63 @@ +cacheAdapter = $cacheAdapter; + } + + /** + * @param NodeTypeFieldInterface $field + * @return array + */ + protected function getNodeTypeList(NodeTypeFieldInterface $field): array + { + $nodeTypesNames = array_map('trim', explode(',', $field->getDefaultValues() ?? '')); + return array_filter($nodeTypesNames); + } + + /** + * @param NodeTypeInterface $nodeType + * @return array + * @throws InvalidArgumentException + */ + public function getChildrenNodeTypeList(NodeTypeInterface $nodeType): array + { + $cacheKey = 'children_' . $nodeType->getName(); + + $cacheItem = $this->cacheAdapter->getItem($cacheKey); + if ($cacheItem->isHit()) { + return $cacheItem->get(); + } + + $childrenTypes = []; + $childrenFields = $nodeType->getFields()->filter(function (NodeTypeFieldInterface $field) { + return $field->isChildrenNodes() && null !== $field->getDefaultValues(); + }); + if ($childrenFields->count() > 0) { + /** @var NodeTypeFieldInterface $field */ + foreach ($childrenFields as $field) { + $childrenTypes = array_merge($childrenTypes, $this->getNodeTypeList($field)); + } + $childrenTypes = array_filter(array_unique($childrenTypes)); + } + + $cacheItem = $this->cacheAdapter->getItem($cacheKey); + $cacheItem->set($childrenTypes); + $this->cacheAdapter->save($cacheItem); + + return $childrenTypes; + } +} diff --git a/lib/RoadizCoreBundle/src/Preview/EventSubscriber/PreviewBarSubscriber.php b/lib/RoadizCoreBundle/src/Preview/EventSubscriber/PreviewBarSubscriber.php new file mode 100644 index 00000000..12fef113 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Preview/EventSubscriber/PreviewBarSubscriber.php @@ -0,0 +1,81 @@ +previewResolver = $previewResolver; + } + + /** + * @return array + */ + public static function getSubscribedEvents(): array + { + return [ + KernelEvents::RESPONSE => ['onKernelResponse', -128] + ]; + } + + /** + * @param ResponseEvent $event + * + * @return bool + */ + protected function supports(ResponseEvent $event): bool + { + $response = $event->getResponse(); + if ( + $this->previewResolver->isPreview() && + $event->isMainRequest() && + $response->getStatusCode() === Response::HTTP_OK && + str_contains($response->headers->get('Content-Type'), 'text/html') + ) { + return true; + } + + return false; + } + + /** + * @param ResponseEvent $event + */ + public function onKernelResponse(ResponseEvent $event): void + { + if ($this->supports($event)) { + $response = $event->getResponse(); + if ( + str_contains($response->getContent(), '') && + str_contains($response->getContent(), '') + ) { + $content = str_replace( + '', + "", + $response->getContent() + ); + $content = str_replace( + '', + "
Preview
", + $content + ); + $response->setContent($content); + $event->setResponse($response); + } + } + } +} diff --git a/lib/RoadizCoreBundle/src/Preview/EventSubscriber/PreviewModeSubscriber.php b/lib/RoadizCoreBundle/src/Preview/EventSubscriber/PreviewModeSubscriber.php new file mode 100644 index 00000000..cc7c3410 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Preview/EventSubscriber/PreviewModeSubscriber.php @@ -0,0 +1,108 @@ +previewResolver = $previewResolver; + $this->tokenStorage = $tokenStorage; + $this->security = $security; + } + + /** + * @return array + */ + public static function getSubscribedEvents(): array + { + return [ + KernelEvents::REQUEST => ['onKernelRequest', 9999], + KernelEvents::CONTROLLER => ['onControllerMatched', 10], + KernelEvents::RESPONSE => 'onResponse', + ]; + } + + /** + * @return bool + */ + protected function supports(): bool + { + return $this->previewResolver->isPreview(); + } + + /** + * @param RequestEvent $event + */ + public function onKernelRequest(RequestEvent $event): void + { + $request = $event->getRequest(); + if ( + $event->isMainRequest() && + $request->query->has(static::QUERY_PARAM_NAME) && + (bool) ($request->query->get(static::QUERY_PARAM_NAME, 0)) === true + ) { + $request->attributes->set('preview', true); + } + } + + /** + * Preview mode security enforcement. + * You MUST check here is user can use preview mode BEFORE going + * any further into your app logic. + * + * @param ControllerEvent $event + * @throws PreviewNotAllowedException + */ + public function onControllerMatched(ControllerEvent $event): void + { + if ($this->supports() && $event->isMainRequest()) { + /** @var TokenInterface|null $token */ + $token = $this->tokenStorage->getToken(); + if (null === $token || !$token->isAuthenticated()) { + throw new PreviewNotAllowedException('You are not authenticated to use preview mode.'); + } + if (!$this->security->isGranted($this->previewResolver->getRequiredRole())) { + throw new PreviewNotAllowedException('You are not granted to use preview mode.'); + } + } + } + + /** + * Enforce cache disabling. + * + * @param ResponseEvent $event + */ + public function onResponse(ResponseEvent $event): void + { + if ($this->supports()) { + $response = $event->getResponse(); + $response->expire(); + $response->headers->addCacheControlDirective('no-store'); + $response->headers->add(['X-Roadiz-Preview' => true]); + $event->setResponse($response); + } + } +} diff --git a/lib/RoadizCoreBundle/src/Preview/Exception/PreviewNotAllowedException.php b/lib/RoadizCoreBundle/src/Preview/Exception/PreviewNotAllowedException.php new file mode 100644 index 00000000..2c376595 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Preview/Exception/PreviewNotAllowedException.php @@ -0,0 +1,19 @@ +requestStack = $requestStack; + $this->requiredRole = $requiredRole; + } + + /** + * @return bool + */ + public function isPreview(): bool + { + $request = $this->requestStack->getMainRequest(); + if (null === $request) { + return false; + } + return $request->attributes->get('preview', false); + } + + public function getRequiredRole(): string + { + return $this->requiredRole; + } +} diff --git a/lib/RoadizCoreBundle/src/Preview/User/PreviewUser.php b/lib/RoadizCoreBundle/src/Preview/User/PreviewUser.php new file mode 100644 index 00000000..5d3fc9e1 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Preview/User/PreviewUser.php @@ -0,0 +1,68 @@ +username = $username; + $this->roles = $roles; + } + + /** + * @inheritDoc + */ + public function getRoles(): array + { + return $this->roles; + } + + /** + * @inheritDoc + */ + public function getPassword(): string + { + throw new \BadMethodCallException('Preview user does not have a password'); + } + + /** + * @inheritDoc + */ + public function getSalt(): string + { + throw new \BadMethodCallException('Preview user does not have a password salt'); + } + + /** + * @inheritDoc + */ + public function eraseCredentials(): void + { + throw new \BadMethodCallException('Preview user cannot erase its credentials'); + } + + /** + * @inheritDoc + */ + public function getUsername(): string + { + return $this->username; + } + + public function getUserIdentifier(): string + { + return $this->username; + } +} diff --git a/lib/RoadizCoreBundle/src/Preview/User/PreviewUserProvider.php b/lib/RoadizCoreBundle/src/Preview/User/PreviewUserProvider.php new file mode 100644 index 00000000..cea9ee39 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Preview/User/PreviewUserProvider.php @@ -0,0 +1,38 @@ +previewResolver = $previewResolver; + $this->security = $security; + } + + public function createFromSecurity(): UserInterface + { + if (!$this->security->isGranted($this->previewResolver->getRequiredRole())) { + throw new AccessDeniedException( + 'Cannot create a preview user proxy from a user that is not allowed to preview.' + ); + } + return new PreviewUser($this->security->getUser()->getUserIdentifier(), [ + $this->previewResolver->getRequiredRole() + ]); + } +} diff --git a/lib/RoadizCoreBundle/src/Preview/User/PreviewUserProviderInterface.php b/lib/RoadizCoreBundle/src/Preview/User/PreviewUserProviderInterface.php new file mode 100644 index 00000000..7aff0210 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Preview/User/PreviewUserProviderInterface.php @@ -0,0 +1,17 @@ +managerRegistry = $managerRegistry; + $this->security = $security; + } + + public function getRealms(?Node $node): array + { + if (null === $node) { + return []; + } + return $this->managerRegistry->getRepository(Realm::class)->findByNode($node); + } + + public function isGranted(RealmInterface $realm): bool + { + return $this->security->isGranted(RealmVoter::READ, $realm); + } + + public function denyUnlessGranted(RealmInterface $realm): void + { + if (!$this->isGranted($realm)) { + throw new UnauthorizedHttpException( + $realm->getChallenge(), + 'WebResponse was denied by Realm authorization, check Www-Authenticate header' + ); + } + } +} diff --git a/lib/RoadizCoreBundle/src/Realm/RealmResolverInterface.php b/lib/RoadizCoreBundle/src/Realm/RealmResolverInterface.php new file mode 100644 index 00000000..970b094f --- /dev/null +++ b/lib/RoadizCoreBundle/src/Realm/RealmResolverInterface.php @@ -0,0 +1,26 @@ + + */ +final class AttributeDocumentsRepository extends EntityRepository +{ + public function __construct( + ManagerRegistry $registry, + EventDispatcherInterface $dispatcher + ) { + parent::__construct($registry, AttributeDocuments::class, $dispatcher); + } + + /** + * @param AttributeInterface $attribute + * + * @return int + * @throws \Doctrine\ORM\NoResultException + * @throws \Doctrine\ORM\NonUniqueResultException + */ + public function getLatestPosition(AttributeInterface $attribute): int + { + $qb = $this->createQueryBuilder('ad'); + $qb->select($qb->expr()->max('ad.position')) + ->andWhere($qb->expr()->eq('ad.attribute', ':attribute')) + ->setParameter('attribute', $attribute); + + return (int) $qb->getQuery()->getSingleScalarResult(); + } +} diff --git a/lib/RoadizCoreBundle/src/Repository/AttributeGroupRepository.php b/lib/RoadizCoreBundle/src/Repository/AttributeGroupRepository.php new file mode 100644 index 00000000..b913a8da --- /dev/null +++ b/lib/RoadizCoreBundle/src/Repository/AttributeGroupRepository.php @@ -0,0 +1,20 @@ + + */ +final class AttributeGroupRepository extends EntityRepository +{ + public function __construct(ManagerRegistry $registry, EventDispatcherInterface $dispatcher) + { + parent::__construct($registry, AttributeGroup::class, $dispatcher); + } +} diff --git a/lib/RoadizCoreBundle/src/Repository/AttributeGroupTranslationRepository.php b/lib/RoadizCoreBundle/src/Repository/AttributeGroupTranslationRepository.php new file mode 100644 index 00000000..00f6e0b1 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Repository/AttributeGroupTranslationRepository.php @@ -0,0 +1,36 @@ + + */ +final class AttributeGroupTranslationRepository extends EntityRepository +{ + public function __construct( + ManagerRegistry $registry, + EventDispatcherInterface $dispatcher + ) { + parent::__construct($registry, AttributeGroupTranslation::class, $dispatcher); + } + + public function findOneByNameAndLocale(string $name, string $locale): ?AttributeGroupTranslationInterface + { + $qb = $this->createQueryBuilder('agt'); + return $qb->innerJoin('agt.translation', 't') + ->andWhere($qb->expr()->eq('t.locale', ':locale')) + ->andWhere($qb->expr()->eq('agt.name', ':name')) + ->setParameter('locale', $locale) + ->setParameter('name', $name) + ->setMaxResults(1) + ->getQuery() + ->getOneOrNullResult(); + } +} diff --git a/lib/RoadizCoreBundle/src/Repository/AttributeRepository.php b/lib/RoadizCoreBundle/src/Repository/AttributeRepository.php new file mode 100644 index 00000000..a7d7da19 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Repository/AttributeRepository.php @@ -0,0 +1,20 @@ + + */ +final class AttributeRepository extends EntityRepository +{ + public function __construct(ManagerRegistry $registry, EventDispatcherInterface $dispatcher) + { + parent::__construct($registry, Attribute::class, $dispatcher); + } +} diff --git a/lib/RoadizCoreBundle/src/Repository/AttributeTranslationRepository.php b/lib/RoadizCoreBundle/src/Repository/AttributeTranslationRepository.php new file mode 100644 index 00000000..785d0faf --- /dev/null +++ b/lib/RoadizCoreBundle/src/Repository/AttributeTranslationRepository.php @@ -0,0 +1,20 @@ + + */ +final class AttributeTranslationRepository extends EntityRepository +{ + public function __construct(ManagerRegistry $registry, EventDispatcherInterface $dispatcher) + { + parent::__construct($registry, AttributeTranslation::class, $dispatcher); + } +} diff --git a/lib/RoadizCoreBundle/src/Repository/AttributeValueRepository.php b/lib/RoadizCoreBundle/src/Repository/AttributeValueRepository.php new file mode 100644 index 00000000..1f4fe5e4 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Repository/AttributeValueRepository.php @@ -0,0 +1,91 @@ + + */ +final class AttributeValueRepository extends EntityRepository +{ + public function __construct( + ManagerRegistry $registry, + EventDispatcherInterface $dispatcher + ) { + parent::__construct($registry, AttributeValue::class, $dispatcher); + } + + /** + * @param AttributableInterface $attributable + * + * @return array + */ + public function findByAttributable( + AttributableInterface $attributable + ): array { + $qb = $this->createQueryBuilder('av'); + return $qb->addSelect('avt') + ->addSelect('a') + ->addSelect('at') + ->addSelect('ad') + ->addSelect('ag') + ->addSelect('agt') + ->innerJoin('av.attributeValueTranslations', 'avt') + ->innerJoin('av.attribute', 'a') + ->leftJoin('a.attributeDocuments', 'ad') + ->leftJoin('a.attributeTranslations', 'at') + ->leftJoin('a.group', 'ag') + ->leftJoin('ag.attributeGroupTranslations', 'agt') + ->andWhere($qb->expr()->eq('av.node', ':attributable')) + ->addOrderBy('av.position', 'ASC') + ->setParameters([ + 'attributable' => $attributable, + ]) + ->setCacheable(true) + ->getQuery() + ->getResult(); + } + + /** + * @param AttributableInterface $attributable + * @param TranslationInterface $translation + * + * @return array + */ + public function findByAttributableAndTranslation( + AttributableInterface $attributable, + TranslationInterface $translation + ): array { + $qb = $this->createQueryBuilder('av'); + return $qb->addSelect('avt') + ->addSelect('a') + ->addSelect('at') + ->addSelect('ad') + ->addSelect('ag') + ->addSelect('agt') + ->innerJoin('av.attributeValueTranslations', 'avt') + ->innerJoin('av.attribute', 'a') + ->leftJoin('a.attributeTranslations', 'at') + ->leftJoin('a.attributeDocuments', 'ad') + ->leftJoin('a.group', 'ag') + ->leftJoin('ag.attributeGroupTranslations', 'agt') + ->andWhere($qb->expr()->eq('av.node', ':attributable')) + ->andWhere($qb->expr()->eq('at.translation', ':translation')) + ->andWhere($qb->expr()->eq('agt.translation', ':translation')) + ->addOrderBy('av.position', 'ASC') + ->setParameters([ + 'attributable' => $attributable, + 'translation' => $translation + ]) + ->setCacheable(true) + ->getQuery() + ->getResult(); + } +} diff --git a/lib/RoadizCoreBundle/src/Repository/AttributeValueTranslationRepository.php b/lib/RoadizCoreBundle/src/Repository/AttributeValueTranslationRepository.php new file mode 100644 index 00000000..eb3cb6e5 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Repository/AttributeValueTranslationRepository.php @@ -0,0 +1,20 @@ + + */ +final class AttributeValueTranslationRepository extends EntityRepository +{ + public function __construct(ManagerRegistry $registry, EventDispatcherInterface $dispatcher) + { + parent::__construct($registry, AttributeValueTranslation::class, $dispatcher); + } +} diff --git a/lib/RoadizCoreBundle/src/Repository/CustomFormAnswerRepository.php b/lib/RoadizCoreBundle/src/Repository/CustomFormAnswerRepository.php new file mode 100644 index 00000000..3810a296 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Repository/CustomFormAnswerRepository.php @@ -0,0 +1,58 @@ +createQueryBuilder('cfa'); + return $qb->andWhere($qb->expr()->eq('cfa.customForm', ':customForm')) + ->andWhere($qb->expr()->lte('cfa.submittedAt', ':submittedAt')); + } + + /** + * @param CustomForm $customForm + * @param \DateTime $submittedAt + * @return Paginator + */ + public function findByCustomFormSubmittedBefore(CustomForm $customForm, \DateTime $submittedAt): Paginator + { + $qb = $this->getCustomFormSubmittedBeforeQueryBuilder() + ->setParameter(':customForm', $customForm) + ->setParameter(':submittedAt', $submittedAt); + return new Paginator($qb->getQuery()); + } + + /** + * @param CustomForm $customForm + * @param \DateTime $submittedAt + * @return int + * @throws NoResultException + * @throws NonUniqueResultException + */ + public function deleteByCustomFormSubmittedBefore(CustomForm $customForm, \DateTime $submittedAt): int + { + $qb = $this->getCustomFormSubmittedBeforeQueryBuilder() + ->delete() + ->setParameter(':customForm', $customForm) + ->setParameter(':submittedAt', $submittedAt); + return $qb->getQuery()->getSingleScalarResult(); + } +} diff --git a/lib/RoadizCoreBundle/src/Repository/CustomFormFieldAttributeRepository.php b/lib/RoadizCoreBundle/src/Repository/CustomFormFieldAttributeRepository.php new file mode 100644 index 00000000..fe4f039d --- /dev/null +++ b/lib/RoadizCoreBundle/src/Repository/CustomFormFieldAttributeRepository.php @@ -0,0 +1,20 @@ + + */ +final class CustomFormFieldAttributeRepository extends EntityRepository +{ + public function __construct(ManagerRegistry $registry, EventDispatcherInterface $dispatcher) + { + parent::__construct($registry, CustomFormFieldAttribute::class, $dispatcher); + } +} diff --git a/lib/RoadizCoreBundle/src/Repository/CustomFormFieldRepository.php b/lib/RoadizCoreBundle/src/Repository/CustomFormFieldRepository.php new file mode 100644 index 00000000..355adc44 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Repository/CustomFormFieldRepository.php @@ -0,0 +1,20 @@ + + */ +final class CustomFormFieldRepository extends EntityRepository +{ + public function __construct(ManagerRegistry $registry, EventDispatcherInterface $dispatcher) + { + parent::__construct($registry, CustomFormField::class, $dispatcher); + } +} diff --git a/lib/RoadizCoreBundle/src/Repository/CustomFormRepository.php b/lib/RoadizCoreBundle/src/Repository/CustomFormRepository.php new file mode 100644 index 00000000..027ea52e --- /dev/null +++ b/lib/RoadizCoreBundle/src/Repository/CustomFormRepository.php @@ -0,0 +1,48 @@ + + */ +final class CustomFormRepository extends EntityRepository +{ + public function __construct( + ManagerRegistry $registry, + EventDispatcherInterface $dispatcher + ) { + parent::__construct($registry, CustomForm::class, $dispatcher); + } + + /** + * @return CustomForm[] + */ + public function findAllWithRetentionTime(): array + { + $qb = $this->createQueryBuilder('cf'); + return $qb->andWhere($qb->expr()->isNotNull('cf.retentionTime')) + ->getQuery() + ->getResult(); + } + + public function findByNodeAndField(Node $node, NodeTypeFieldInterface $field): array + { + $query = $this->_em->createQuery(' + SELECT cf FROM RZ\Roadiz\CoreBundle\Entity\CustomForm cf + INNER JOIN cf.nodes ncf + WHERE ncf.field = :field AND ncf.node = :node + ORDER BY ncf.position ASC') + ->setParameter('field', $field) + ->setParameter('node', $node); + + return $query->getResult(); + } +} diff --git a/lib/RoadizCoreBundle/src/Repository/DocumentRepository.php b/lib/RoadizCoreBundle/src/Repository/DocumentRepository.php new file mode 100644 index 00000000..89f6cbe9 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Repository/DocumentRepository.php @@ -0,0 +1,681 @@ + + * @implements DocumentRepositoryInterface + */ +final class DocumentRepository extends EntityRepository implements DocumentRepositoryInterface +{ + public function __construct( + ManagerRegistry $registry, + EventDispatcherInterface $dispatcher + ) { + parent::__construct($registry, Document::class, $dispatcher); + } + + /** + * Get a document with its translation id. + * + * @param int $id + * @return Document|null + * @throws NonUniqueResultException + */ + public function findOneByDocumentTranslationId(int $id): ?Document + { + $qb = $this->createQueryBuilder('d'); + $qb->select('d, dt') + ->innerJoin('d.documentTranslations', 'dt') + ->andWhere($qb->expr()->eq('dt.id', ':id')) + ->setParameter(':id', $id) + ->setMaxResults(1); + + return $qb->getQuery()->getOneOrNullResult(); + } + + protected function getCustomFormSubmittedBeforeQueryBuilder(): QueryBuilder + { + $qb = $this->createQueryBuilder('d'); + return $qb->innerJoin('d.customFormFieldAttributes', 'cffa') + ->innerJoin('cffa.customFormAnswer', 'cfa') + ->andWhere($qb->expr()->eq('cfa.customForm', ':customForm')) + ->andWhere($qb->expr()->lte('cfa.submittedAt', ':submittedAt')); + } + + /** + * @param CustomForm $customForm + * @param \DateTime $submittedAt + * @return array + */ + public function findByCustomFormSubmittedBefore(CustomForm $customForm, \DateTime $submittedAt): array + { + $qb = $this->getCustomFormSubmittedBeforeQueryBuilder() + ->setParameter(':customForm', $customForm) + ->setParameter(':submittedAt', $submittedAt); + return $qb->getQuery()->getResult(); + } + + /** + * Add a folder filtering to queryBuilder. + * + * @param array $criteria + * @param QueryBuilder $qb + * @param string $prefix + */ + protected function filterByFolder(array &$criteria, QueryBuilder $qb, string $prefix = 'd'): void + { + if (key_exists('folders', $criteria)) { + /* + * Do not filter if folder is null + */ + if (is_null($criteria['folders'])) { + return; + } + + if (is_array($criteria['folders']) || $criteria['folders'] instanceof Collection) { + /* + * Do not filter if folder array is empty. + */ + if (count($criteria['folders']) === 0) { + return; + } + if ( + in_array("folderExclusive", array_keys($criteria)) + && $criteria["folderExclusive"] === true + ) { + // To get an exclusive folder filter + // we need to filter against each folder id + // and to inner join with a different alias for each folder + // with AND operator + foreach ($criteria['folders'] as $index => $folder) { + if (null !== $folder && $folder instanceof Folder) { + $alias = 'fd' . $index; + $qb->innerJoin($prefix . '.folders', $alias); + $qb->andWhere($qb->expr()->eq($alias . '.id', $folder->getId())); + } + } + unset($criteria["folderExclusive"]); + unset($criteria['folders']); + } else { + $qb->innerJoin( + $prefix . '.folders', + 'fd', + 'WITH', + 'fd.id IN (:folders)' + ); + } + } else { + $qb->innerJoin( + $prefix . '.folders', + 'fd', + 'WITH', + 'fd.id = :folders' + ); + } + } + } + + /** + * Reimplementing findBy features… with extra things + * + * * key => array('<=', $value) + * * key => array('<', $value) + * * key => array('>=', $value) + * * key => array('>', $value) + * * key => array('BETWEEN', $value, $value) + * * key => array('LIKE', $value) + * * key => array('NOT IN', $array) + * * key => 'NOT NULL' + * + * You can filter with translations relation, examples: + * + * * `translation => $object` + * * `translation.locale => 'fr_FR'` + * + * @param array $criteria + * @param QueryBuilder $qb + */ + protected function filterByCriteria(array &$criteria, QueryBuilder $qb): void + { + $simpleQB = new SimpleQueryBuilder($qb); + /* + * Reimplementing findBy features… + */ + foreach ($criteria as $key => $value) { + if ($key == "folders" || $key == "folderExclusive") { + continue; + } + + /* + * compute prefix for + * filtering node, and sources relation fields + */ + $prefix = 'd.'; + + // Dots are forbidden in field definitions + $baseKey = $simpleQB->getParameterKey($key); + /* + * Search in translation fields + */ + if (str_contains($key, 'translation.')) { + $prefix = 't.'; + $key = str_replace('translation.', '', $key); + } elseif (str_contains($key, 'documentTranslations.')) { + /* + * Search in translation fields + */ + $prefix = 'dt.'; + $key = str_replace('documentTranslations.', '', $key); + } elseif ($key == 'translation') { + $prefix = 'dt.'; + } + + $qb->andWhere($simpleQB->buildExpressionWithoutBinding($value, $prefix, $key, $baseKey)); + } + } + + /** + * Create a Criteria object from a search pattern and additional fields. + * + * @param string $pattern Search pattern + * @param QueryBuilder $qb QueryBuilder to pass + * @param array $criteria Additional criteria + * @param string $alias SQL query table alias + * + * @return QueryBuilder + */ + protected function createSearchBy( + string $pattern, + QueryBuilder $qb, + array &$criteria = [], + string $alias = "obj" + ): QueryBuilder { + $this->filterByFolder($criteria, $qb, $alias); + $this->applyFilterByFolder($criteria, $qb); + $this->classicLikeComparison($pattern, $qb, $alias); + + /* + * Search in translations + */ + $qb->leftJoin($alias . '.documentTranslations', 'dt'); + $criteriaFields = []; + $metadata = $this->_em->getClassMetadata(DocumentTranslation::class); + $cols = $metadata->getColumnNames(); + foreach ($cols as $col) { + $field = $metadata->getFieldName($col); + $type = $metadata->getTypeOfField($field); + if (in_array($type, $this->searchableTypes)) { + $criteriaFields[$field] = '%' . strip_tags((string) $pattern) . '%'; + } + } + foreach ($criteriaFields as $key => $value) { + $fullKey = sprintf('LOWER(%s)', 'dt.' . $key); + $qb->orWhere($qb->expr()->like($fullKey, $qb->expr()->literal($value))); + } + + return $this->prepareComparisons($criteria, $qb, $alias); + } + + /** + * Bind parameters to generated query. + * + * @param array $criteria + * @param QueryBuilder $qb + */ + protected function applyFilterByCriteria(array &$criteria, QueryBuilder $qb): void + { + /* + * Reimplementing findBy features… + */ + $simpleQB = new SimpleQueryBuilder($qb); + foreach ($criteria as $key => $value) { + if ($key == "folders" || $key == "folderExclusive") { + continue; + } + $simpleQB->bindValue($key, $value); + } + } + + /** + * Bind tag parameter to final query + * + * @param array $criteria + * @param QueryBuilder $qb + */ + protected function applyFilterByFolder(array &$criteria, QueryBuilder $qb): void + { + if (key_exists('folders', $criteria)) { + if ($criteria['folders'] instanceof Folder) { + $qb->setParameter('folders', $criteria['folders']->getId()); + } elseif (is_array($criteria['folders']) || $criteria['folders'] instanceof Collection) { + if (count($criteria['folders']) > 0) { + $qb->setParameter('folders', $criteria['folders']); + } + } elseif (is_integer($criteria['folders'])) { + $qb->setParameter('folders', (int) $criteria['folders']); + } + unset($criteria["folders"]); + } + } + + /** + * Bind translation parameter to final query + * + * @param QueryBuilder $qb + * @param null|TranslationInterface $translation + */ + protected function applyTranslationByFolder( + QueryBuilder $qb, + TranslationInterface $translation = null + ): void { + if (null !== $translation) { + $qb->setParameter('translation', $translation); + } + } + + /** + * Restrict documents to their copyright valid datetime range or null. + * + * @param QueryBuilder $qb + * @param string $alias + * @return QueryBuilder + */ + public function alterQueryBuilderWithCopyrightLimitations(QueryBuilder $qb, string $alias = 'd'): QueryBuilder + { + return $qb->andWhere($qb->expr()->orX( + $qb->expr()->isNull($alias . '.copyrightValidSince'), + $qb->expr()->lte($alias . '.copyrightValidSince', ':now') + ))->andWhere($qb->expr()->orX( + $qb->expr()->isNull($alias . '.copyrightValidUntil'), + $qb->expr()->gte($alias . '.copyrightValidUntil', ':now') + ))->setParameter(':now', new \DateTime()); + } + + /** + * Create filters according to any translation criteria OR argument. + * + * @param array $criteria + * @param QueryBuilder $qb + * @param TranslationInterface|null $translation + */ + protected function filterByTranslation(array &$criteria, QueryBuilder $qb, TranslationInterface $translation = null): void + { + if ( + isset($criteria['translation']) || + isset($criteria['translation.locale']) || + isset($criteria['translation.id']) + ) { + $qb->leftJoin('d.documentTranslations', 'dt'); + $qb->leftJoin('dt.translation', 't'); + } else { + if (null !== $translation) { + /* + * With a given translation + */ + $qb->leftJoin( + 'd.documentTranslations', + 'dt', + 'WITH', + 'dt.translation = :translation' + ); + } else { + /* + * With a null translation, just take the default one optionally + * Using left join instead of inner join. + */ + $qb->leftJoin('d.documentTranslations', 'dt'); + $qb->leftJoin( + 'dt.translation', + 't', + 'WITH', + 't.defaultTranslation = true' + ); + } + } + } + + /** + * This method allows to pre-filter Documents with a given translation. + * + * @param array $criteria + * @param array|null $orderBy + * @param int|null $limit + * @param int|null $offset + * @param TranslationInterface|null $translation + * + * @return QueryBuilder + */ + protected function getContextualQueryWithTranslation( + array &$criteria, + array $orderBy = null, + ?int $limit = null, + ?int $offset = null, + TranslationInterface $translation = null + ): QueryBuilder { + $qb = $this->createQueryBuilder('d'); + $qb->andWhere($qb->expr()->eq('d.raw', ':raw')) + ->setParameter('raw', false); + + /* + * Filtering by tag + */ + $this->filterByTranslation($criteria, $qb, $translation); + $this->filterByFolder($criteria, $qb); + $this->filterByCriteria($criteria, $qb); + + // Add ordering + if (null !== $orderBy) { + foreach ($orderBy as $key => $value) { + $qb->addOrderBy('d.' . $key, $value); + } + } + + if (null !== $offset) { + $qb->setFirstResult($offset); + } + if (null !== $limit) { + $qb->setMaxResults($limit); + } + + return $qb; + } + + /** + * This method allows to pre-filter Documents with a given translation. + * + * @param array $criteria + * @param TranslationInterface|null $translation + * + * @return QueryBuilder + */ + protected function getCountContextualQueryWithTranslation( + array &$criteria, + TranslationInterface $translation = null + ): QueryBuilder { + $qb = $this->getContextualQueryWithTranslation($criteria, null, null, null, $translation); + return $qb->select($qb->expr()->countDistinct('d.id')); + } + + /** + * Just like the findBy method but with relational criteria. + * + * @param array $criteria + * @param array|null $orderBy + * @param int|null $limit + * @param int|null $offset + * @param TranslationInterface|null $translation + * + * @return array|Paginator + */ + public function findBy( + array $criteria, + array $orderBy = null, + $limit = null, + $offset = null, + TranslationInterface $translation = null + ): array|Paginator { + $qb = $this->getContextualQueryWithTranslation( + $criteria, + $orderBy, + $limit, + $offset, + $translation + ); + + $this->dispatchQueryBuilderEvent($qb, $this->getEntityName()); + $this->applyFilterByFolder($criteria, $qb); + $this->applyFilterByCriteria($criteria, $qb); + $query = $qb->getQuery(); + $this->dispatchQueryEvent($query); + + if ( + null !== $limit && + null !== $offset + ) { + /* + * We need to use Doctrine paginator + * if a limit is set because of the default inner join + */ + return new Paginator($query); + } else { + return $query->getResult(); + } + } + + /** + * Just like the findOneBy method but with relational criteria. + * + * @param array $criteria + * @param array|null $orderBy + * @param TranslationInterface|null $translation + * + * @return Document|null + * @throws \Doctrine\ORM\NonUniqueResultException + */ + public function findOneBy( + array $criteria, + array $orderBy = null, + TranslationInterface $translation = null + ): ?Document { + $qb = $this->getContextualQueryWithTranslation( + $criteria, + $orderBy, + 1, + 0, + $translation + ); + + $this->dispatchQueryBuilderEvent($qb, $this->getEntityName()); + $this->applyFilterByFolder($criteria, $qb); + $this->applyFilterByCriteria($criteria, $qb); + $query = $qb->getQuery(); + $this->dispatchQueryEvent($query); + + return $query->getOneOrNullResult(); + } + + /** + * Just like the countBy method but with relational criteria. + * + * @param Criteria|mixed|array $criteria + * @param TranslationInterface|null $translation + * + * @return int + * @throws NonUniqueResultException + * @throws NoResultException + */ + public function countBy( + mixed $criteria, + TranslationInterface $translation = null + ): int { + if ($criteria instanceof Criteria) { + $collection = $this->matching($criteria); + return $collection->count(); + } elseif (\is_array($criteria)) { + $query = $this->getCountContextualQueryWithTranslation( + $criteria, + $translation + ); + + $this->dispatchQueryBuilderEvent($query, $this->getEntityName()); + $this->applyFilterByFolder($criteria, $query); + $this->applyFilterByCriteria($criteria, $query); + + return (int) $query->getQuery()->getSingleScalarResult(); + } + return 0; + } + + /** + * @param NodesSources $nodeSource + * @param NodeTypeFieldInterface $field + * @return array + */ + public function findByNodeSourceAndField( + NodesSources $nodeSource, + NodeTypeFieldInterface $field + ): array { + $qb = $this->createQueryBuilder('d'); + $qb->addSelect('dt') + ->leftJoin('d.documentTranslations', 'dt', 'WITH', 'dt.translation = :translation') + ->innerJoin('d.nodesSourcesByFields', 'nsf', 'WITH', 'nsf.nodeSource = :nodeSource') + ->andWhere($qb->expr()->eq('nsf.field', ':field')) + ->andWhere($qb->expr()->eq('d.raw', ':raw')) + ->addOrderBy('nsf.position', 'ASC') + ->setParameter('field', $field) + ->setParameter('nodeSource', $nodeSource) + ->setParameter('translation', $nodeSource->getTranslation()) + ->setParameter('raw', false) + ->setCacheable(true); + + return $qb->getQuery()->getResult(); + } + + /** + * Find documents used as Settings. + * + * @return array + */ + public function findAllSettingDocuments(): array + { + $query = $this->_em->createQuery(' + SELECT d FROM RZ\Roadiz\CoreBundle\Entity\Document d + WHERE d.id IN ( + SELECT s.value FROM RZ\Roadiz\CoreBundle\Entity\Setting s + WHERE s.type = :type + ) AND d.raw = :raw + ')->setParameter('type', AbstractField::DOCUMENTS_T) + ->setParameter('raw', false); + + return $query->getResult(); + } + + /** + * Find all unused document. + * + * @return array + */ + public function findAllUnused(): array + { + return $this->getAllUnusedQueryBuilder()->getQuery()->getResult(); + } + + /** + * @return QueryBuilder + */ + public function getAllUnusedQueryBuilder(): QueryBuilder + { + $qb1 = $this->createQueryBuilder('d1'); + $qb2 = $this->_em->createQueryBuilder(); + + /* + * Get documents used by settings + */ + $qb2->select('s.value') + ->from(Setting::class, 's') + ->andWhere($qb2->expr()->eq('s.type', ':type')) + ->andWhere($qb2->expr()->isNotNull('s.value')) + ->setParameter('type', AbstractField::DOCUMENTS_T); + + $subQuery = $qb2->getQuery(); + $array = $subQuery->getScalarResult(); + $idArray = []; + + foreach ($array as $value) { + $idArray[] = (int) $value['value']; + } + + /* + * Get unused documents + */ + $qb1->select('d1.id') + ->leftJoin('d1.nodesSourcesByFields', 'ns') + ->leftJoin('d1.tagTranslations', 'ttd') + ->leftJoin('d1.attributeDocuments', 'ad') + ->andHaving('COUNT(ns.id) = 0') + ->andHaving('COUNT(ttd.id) = 0') + ->andHaving('COUNT(ad.id) = 0') + ->groupBy('d1.id') + ->andWhere($qb1->expr()->eq('d1.raw', ':raw')) + ->andWhere($qb1->expr()->isNull('d1.original')); + + if (count($idArray) > 0) { + $qb1->andWhere($qb1->expr()->notIn( + 'd1.id', + $idArray + )); + } + + $qb = $this->createQueryBuilder('d'); + $qb->andWhere($qb->expr()->in( + 'd.id', + $qb1->getQuery()->getDQL() + )) + ->setParameter('raw', false); + + return $qb; + } + + /** + * @return Document[] + */ + public function findAllWithoutFileHash(): array + { + $qb = $this->createQueryBuilder('d'); + return $qb->andWhere($qb->expr()->isNull('d.fileHash')) + ->getQuery() + ->getResult(); + } + + public function getDuplicatesQueryBuilder(): QueryBuilder + { + $qb = $this->createQueryBuilder('d2'); + $qb->select('d2.fileHash') + ->addGroupBy('d2.fileHash') + ->addGroupBy('d2.fileHashAlgorithm') + ->andWhere($qb->expr()->eq('d2.raw', ':raw')) + ->andHaving($qb->expr()->gt($qb->expr()->count('d2.fileHash'), 1)) + ->andHaving($qb->expr()->gt($qb->expr()->count('d2.fileHashAlgorithm'), 1)); + + + $qb2 = $this->createQueryBuilder('d'); + $qb2->andWhere($qb2->expr()->in('d.fileHash', $qb->getDQL())) + ->setParameter(':raw', false) + ->addOrderBy('d.fileHashAlgorithm', 'ASC') + ->addOrderBy('d.fileHash', 'ASC') + ; + + return $qb2; + } + + /** + * @return array + */ + public function findDuplicates(): array + { + return $this->getDuplicatesQueryBuilder()->getQuery()->getResult(); + } +} diff --git a/lib/RoadizCoreBundle/src/Repository/DocumentTranslationRepository.php b/lib/RoadizCoreBundle/src/Repository/DocumentTranslationRepository.php new file mode 100644 index 00000000..c6a6906f --- /dev/null +++ b/lib/RoadizCoreBundle/src/Repository/DocumentTranslationRepository.php @@ -0,0 +1,40 @@ + + */ +final class DocumentTranslationRepository extends EntityRepository +{ + public function __construct( + ManagerRegistry $registry, + EventDispatcherInterface $dispatcher + ) { + parent::__construct($registry, DocumentTranslation::class, $dispatcher); + } + + /** + * @param int $id + * @return DocumentTranslation|null + * @throws NonUniqueResultException + */ + public function findOneWithDocument(int $id): ?DocumentTranslation + { + $qb = $this->createQueryBuilder('dt'); + $qb->select('dt, d') + ->innerJoin('dt.document', 'd') + ->andWhere($qb->expr()->eq('dt.id', ':id')) + ->setMaxResults(1) + ->setParameter(':id', $id); + + return $qb->getQuery()->getOneOrNullResult(); + } +} diff --git a/lib/RoadizCoreBundle/src/Repository/EntityRepository.php b/lib/RoadizCoreBundle/src/Repository/EntityRepository.php new file mode 100644 index 00000000..1ac75e6b --- /dev/null +++ b/lib/RoadizCoreBundle/src/Repository/EntityRepository.php @@ -0,0 +1,525 @@ + + */ +abstract class EntityRepository extends ServiceEntityRepository +{ + protected EventDispatcherInterface $dispatcher; + + /** + * @param ManagerRegistry $registry + * @param string $entityClass + * @param EventDispatcherInterface $dispatcher + */ + public function __construct( + ManagerRegistry $registry, + string $entityClass, + EventDispatcherInterface $dispatcher + ) { + parent::__construct($registry, $entityClass); + $this->dispatcher = $dispatcher; + } + + /** + * Alias for DQL and Query builder representing Node relation. + */ + public const DEFAULT_ALIAS = 'obj'; + + /** + * Alias for DQL and Query builder representing Node relation. + */ + public const NODE_ALIAS = 'n'; + + /** + * Alias for DQL and Query builder representing NodesSources relation. + */ + public const NODESSOURCES_ALIAS = 'ns'; + + /** + * Alias for DQL and Query builder representing Translation relation. + */ + public const TRANSLATION_ALIAS = 't'; + + /** + * Alias for DQL and Query builder representing Tag relation. + */ + public const TAG_ALIAS = 'tg'; + + /** + * Alias for DQL and Query builder representing NodeType relation. + */ + public const NODETYPE_ALIAS = 'nt'; + + /** + * Doctrine column types that can be search + * with LIKE feature. + * + * @var array + */ + protected array $searchableTypes = ['string', 'text']; + + /** + * @param QueryBuilder $qb + * @param class-string $entityClass + */ + protected function dispatchQueryBuilderEvent(QueryBuilder $qb, string $entityClass): void + { + $this->dispatcher->dispatch(new QueryBuilderSelectEvent($qb, $entityClass)); + } + + /** + * @param QueryBuilder $qb + * @param string $property + * @param mixed $value + * + * @return object|QueryBuilderBuildEvent + */ + protected function dispatchQueryBuilderBuildEvent(QueryBuilder $qb, string $property, mixed $value): object + { + return $this->dispatcher->dispatch(new QueryBuilderBuildEvent( + $qb, + $this->getEntityName(), + $property, + $value, + $this->getEntityName() + )); + } + + /** + * @param Query $query + * + * @return object|QueryEvent + */ + protected function dispatchQueryEvent(Query $query): object + { + return $this->dispatcher->dispatch(new QueryEvent( + $query, + $this->getEntityName() + )); + } + + /** + * @param QueryBuilder $qb + * @param string $property + * @param mixed $value + * + * @return object|QueryBuilderApplyEvent + */ + protected function dispatchQueryBuilderApplyEvent(QueryBuilder $qb, string $property, mixed $value): object + { + return $this->dispatcher->dispatch(new QueryBuilderApplyEvent( + $qb, + $this->getEntityName(), + $property, + $value, + $this->getEntityName() + )); + } + + /** + * + * @param array $criteria + * @param QueryBuilder $qb + * @param string $alias + * @return QueryBuilder + */ + protected function prepareComparisons(array &$criteria, QueryBuilder $qb, string $alias) + { + $simpleQB = new SimpleQueryBuilder($qb); + foreach ($criteria as $key => $value) { + /* + * Main QueryBuilder dispatch loop for + * custom properties criteria. + */ + $event = $this->dispatchQueryBuilderBuildEvent($qb, $key, $value); + + if (!$event->isPropagationStopped()) { + $qb->andWhere($simpleQB->buildExpressionWithoutBinding($value, $alias . '.', $key)); + } + } + + return $qb; + } + + /** + * @param array $criteria + * @param QueryBuilder $qb + */ + protected function applyFilterByCriteria(array &$criteria, QueryBuilder $qb): void + { + $simpleQB = new SimpleQueryBuilder($qb); + foreach ($criteria as $key => $value) { + $event = $this->dispatchQueryBuilderApplyEvent($qb, $key, $value); + if (!$event->isPropagationStopped()) { + $simpleQB->bindValue($key, $value); + } + } + } + + /** + * @param QueryBuilder $qb + * @param string $name + * @param string $key + * @param mixed $value + * + * @return Query\Expr\Func + */ + protected function directExprIn(QueryBuilder $qb, string $name, string $key, mixed $value): Query\Expr\Func + { + $newValue = []; + + if (is_array($value)) { + foreach ($value as $singleValue) { + if ($singleValue instanceof PersistableInterface) { + $newValue[] = $singleValue->getId(); + } else { + $newValue[] = $value; + } + } + } + + return $qb->expr()->in($name, $newValue); + } + + /** + * Count entities using a Criteria object or a simple filter array. + * + * @param Criteria|mixed|array $criteria or array + * + * @return int + */ + public function countBy(mixed $criteria): int + { + if ($criteria instanceof Criteria) { + $collection = $this->matching($criteria); + return $collection->count(); + } elseif (is_array($criteria)) { + $qb = $this->createQueryBuilder(static::DEFAULT_ALIAS); + $qb->select($qb->expr()->countDistinct(static::DEFAULT_ALIAS . '.id')); + $qb = $this->prepareComparisons($criteria, $qb, static::DEFAULT_ALIAS); + $this->dispatchQueryBuilderEvent($qb, $this->getEntityName()); + $this->applyFilterByCriteria($criteria, $qb); + + try { + return (int) $qb->getQuery()->getSingleScalarResult(); + } catch (NoResultException | NonUniqueResultException $e) { + return 0; + } + } + return 0; + } + + /** + * Create a LIKE comparison with entity texts colunms. + * + * @param string $pattern + * @param QueryBuilder $qb + * @param string $alias + * @return QueryBuilder + */ + protected function classicLikeComparison( + string $pattern, + QueryBuilder $qb, + string $alias = EntityRepository::DEFAULT_ALIAS + ): QueryBuilder { + /* + * Get fields needed for a search query + */ + $metadata = $this->_em->getClassMetadata($this->getEntityName()); + $criteriaFields = []; + $cols = $metadata->getColumnNames(); + foreach ($cols as $col) { + $field = $metadata->getFieldName($col); + $type = $metadata->getTypeOfField($field); + if ( + in_array($type, $this->searchableTypes) && + $field != 'folder' && + $field != 'childrenOrder' && + $field != 'childrenOrderDirection' + ) { + $criteriaFields[$field] = '%' . strip_tags((string) $pattern) . '%'; + } + } + + foreach ($criteriaFields as $key => $value) { + $fullKey = sprintf('LOWER(%s)', $alias . '.' . $key); + $qb->orWhere($qb->expr()->like($fullKey, $qb->expr()->literal($value))); + } + + return $qb; + } + + /** + * Create a Criteria object from a search pattern and additional fields. + * + * @param string $pattern Search pattern + * @param QueryBuilder $qb QueryBuilder to pass + * @param array $criteria Additional criteria + * @param string $alias SQL query table alias + * @return QueryBuilder + */ + protected function createSearchBy( + string $pattern, + QueryBuilder $qb, + array &$criteria = [], + string $alias = EntityRepository::DEFAULT_ALIAS + ): QueryBuilder { + $this->classicLikeComparison($pattern, $qb, $alias); + $this->prepareComparisons($criteria, $qb, $alias); + + return $qb; + } + + /** + * @param string $pattern Search pattern + * @param array $criteria Additional criteria + * @param array $orders + * @param int|null $limit + * @param int|null $offset + * @param string $alias + * + * @return array|Paginator + * @psalm-return array|Paginator + */ + public function searchBy( + string $pattern, + array $criteria = [], + array $orders = [], + ?int $limit = null, + ?int $offset = null, + string $alias = EntityRepository::DEFAULT_ALIAS + ): array|Paginator { + $qb = $this->createQueryBuilder($alias); + $qb = $this->createSearchBy($pattern, $qb, $criteria, $alias); + + // Add ordering + foreach ($orders as $key => $value) { + if ( + str_contains($key, static::NODE_ALIAS . '.') && + $this->hasJoinedNode($qb, $alias) + ) { + $qb->addOrderBy($key, $value); + } elseif ( + str_contains($key, static::NODESSOURCES_ALIAS . '.') && + $this->hasJoinedNodesSources($qb, $alias) + ) { + $qb->addOrderBy($key, $value); + } else { + $qb->addOrderBy($alias . '.' . $key, $value); + } + } + if (null !== $offset) { + $qb->setFirstResult($offset); + } + if (null !== $limit) { + $qb->setMaxResults($limit); + } + + $this->dispatchQueryBuilderEvent($qb, $this->getEntityName()); + $this->applyFilterByCriteria($criteria, $qb); + $query = $qb->getQuery(); + $this->dispatchQueryEvent($query); + + if ( + null !== $limit && + null !== $offset + ) { + /* + * We need to use Doctrine paginator + * if a limit is set because of the default inner join + */ + return new Paginator($query); + } else { + return $query->getResult(); + } + } + + /** + * @param string $pattern Search pattern + * @param array $criteria Additional criteria + * @return int + */ + public function countSearchBy(string $pattern, array $criteria = []): int + { + $qb = $this->createQueryBuilder(static::DEFAULT_ALIAS); + $qb->select($qb->expr()->countDistinct(static::DEFAULT_ALIAS . '.id')); + $qb = $this->createSearchBy($pattern, $qb, $criteria); + + $this->dispatchQueryBuilderEvent($qb, $this->getEntityName()); + $this->applyFilterByCriteria($criteria, $qb); + + try { + return (int) $qb->getQuery()->getSingleScalarResult(); + } catch (NoResultException | NonUniqueResultException $e) { + return 0; + } + } + + /** + * @param array $criteria + * @param QueryBuilder $qb + * @param string $nodeAlias + */ + protected function buildTagFiltering(array &$criteria, QueryBuilder $qb, string $nodeAlias = 'n'): void + { + if (key_exists('tags', $criteria)) { + /* + * Do not filter if tag is null + */ + if (is_null($criteria['tags'])) { + return; + } + + if (is_array($criteria['tags']) || $criteria['tags'] instanceof Collection) { + /* + * Do not filter if tag array is empty. + */ + if (count($criteria['tags']) === 0) { + return; + } + if ( + in_array("tagExclusive", array_keys($criteria)) + && $criteria["tagExclusive"] === true + ) { + // To get an exclusive tag filter + // we need to filter against each tag id + // and to inner join with a different alias for each tag + // with AND operator + /** + * @var int $index + * @var Tag|null $tag Tag can be null if not found + */ + foreach ($criteria['tags'] as $index => $tag) { + if ($tag instanceof Tag) { + $alias = 'ntg_' . $index; + $qb->innerJoin($nodeAlias . '.nodesTags', $alias); + $qb->andWhere($qb->expr()->eq($alias . '.tag', $tag->getId())); + } + } + unset($criteria["tagExclusive"]); + unset($criteria['tags']); + } else { + $qb->innerJoin( + $nodeAlias . '.nodesTags', + 'ntg_0', + 'WITH', + 'ntg_0.tag IN (:tags)' + ); + } + } else { + $qb->innerJoin( + $nodeAlias . '.nodesTags', + 'ntg_0', + 'WITH', + 'ntg_0.tag = :tags' + ); + } + } + } + + /** + * Bind tag parameters to final query + * + * @param array $criteria + * @param QueryBuilder $qb + */ + protected function applyFilterByTag(array &$criteria, QueryBuilder $qb): void + { + if (key_exists('tags', $criteria)) { + if ($criteria['tags'] instanceof Tag) { + $qb->setParameter('tags', $criteria['tags']->getId()); + } elseif (is_array($criteria['tags']) || $criteria['tags'] instanceof Collection) { + if (count($criteria['tags']) > 0) { + $qb->setParameter('tags', $criteria['tags']); + } + } elseif (is_integer($criteria['tags'])) { + $qb->setParameter('tags', (int) $criteria['tags']); + } + unset($criteria['tags']); + } + } + + /** + * Ensure that node table is joined only once. + * + * @param QueryBuilder $qb + * @param string $alias + * @return boolean + */ + protected function hasJoinedNode(QueryBuilder $qb, string $alias) + { + return $this->joinExists($qb, $alias, static::NODE_ALIAS); + } + + /** + * Ensure that nodes_sources table is joined only once. + * + * @param QueryBuilder $qb + * @param string $alias + * @return boolean + */ + protected function hasJoinedNodesSources(QueryBuilder $qb, string $alias) + { + return $this->joinExists($qb, $alias, static::NODESSOURCES_ALIAS); + } + + /** + * Ensure that nodes_sources table is joined only once. + * + * @param QueryBuilder $qb + * @param string $alias + * @return boolean + */ + protected function hasJoinedNodeType(QueryBuilder $qb, string $alias) + { + return $this->joinExists($qb, $alias, static::NODETYPE_ALIAS); + } + + /** + * @param QueryBuilder $qb + * @param string $rootAlias + * @param string $joinAlias + * @return bool + */ + protected function joinExists(QueryBuilder $qb, string $rootAlias, string $joinAlias) + { + if (isset($qb->getDQLPart('join')[$rootAlias])) { + foreach ($qb->getDQLPart('join')[$rootAlias] as $join) { + if ( + null !== $join && + $join instanceof Join && + $join->getAlias() === $joinAlias + ) { + return true; + } + } + } + + return false; + } +} diff --git a/lib/RoadizCoreBundle/src/Repository/FolderRepository.php b/lib/RoadizCoreBundle/src/Repository/FolderRepository.php new file mode 100644 index 00000000..befe2d6e --- /dev/null +++ b/lib/RoadizCoreBundle/src/Repository/FolderRepository.php @@ -0,0 +1,305 @@ + + */ +final class FolderRepository extends EntityRepository +{ + public function __construct( + ManagerRegistry $registry, + EventDispatcherInterface $dispatcher + ) { + parent::__construct($registry, Folder::class, $dispatcher); + } + + /** + * Find a folder according to the given path or create it. + * + * @param string $folderPath + * @param TranslationInterface|null $translation + * + * @return Folder|null + * @throws ORMException + * @throws OptimisticLockException + */ + public function findOrCreateByPath(string $folderPath, ?TranslationInterface $translation = null): ?Folder + { + $folderPath = trim($folderPath); + $folders = explode('/', $folderPath); + $folders = array_filter($folders); + + if (count($folders) === 0) { + return null; + } + + $folderName = $folders[count($folders) - 1]; + $folder = $this->findOneByFolderName($folderName); + + if (null === $folder) { + /* + * Creation of a new folder + * before linking it to the node + */ + $parentFolder = null; + + if (count($folders) > 1) { + $parentName = $folders[count($folders) - 2]; + $parentFolder = $this->findOneByFolderName($parentName); + } + $folder = new Folder(); + $folder->setFolderName($folderName); + + if (null !== $parentFolder) { + $folder->setParent($parentFolder); + } + + /* + * Add folder translation + * with given name + */ + if (null === $translation) { + $translation = $this->_em->getRepository(Translation::class)->findDefault(); + } + $folderTranslation = new FolderTranslation($folder, $translation); + $folderTranslation->setName($folderName); + + $this->_em->persist($folder); + $this->_em->persist($folderTranslation); + $this->_em->flush(); + } + + return $folder; + } + + /** + * Find a folder according to the given path. + * + * @param string $folderPath + * + * @return Folder|null + * @throws NonUniqueResultException + */ + public function findByPath(string $folderPath): ?Folder + { + $folderPath = trim($folderPath); + + $folders = explode('/', $folderPath); + $folders = array_filter($folders); + $folderName = $folders[count($folders) - 1]; + + return $this->findOneByFolderName($folderName); + } + + /** + * @param Folder $folder + * @param TranslationInterface|null $translation + * @return array + */ + public function findAllChildrenFromFolder(Folder $folder, TranslationInterface $translation = null): array + { + $ids = $this->findAllChildrenIdFromFolder($folder); + if (count($ids) > 0) { + $qb = $this->createQueryBuilder('f'); + $qb->addSelect('f') + ->andWhere($qb->expr()->in('f.id', ':ids')) + ->setParameter(':ids', $ids); + + if (null !== $translation) { + $qb->addSelect('tf') + ->leftJoin('f.translatedFolders', 'tf') + ->andWhere($qb->expr()->eq('tf.translation', ':translation')) + ->setParameter(':translation', $translation); + } + return $qb->getQuery()->getResult(); + } + return []; + } + + /** + * @param string $folderName + * @param TranslationInterface|null $translation + * + * @return Folder|null + * @throws NonUniqueResultException + */ + public function findOneByFolderName(string $folderName, TranslationInterface $translation = null): ?Folder + { + $qb = $this->createQueryBuilder('f'); + $qb->addSelect('f') + ->andWhere($qb->expr()->in('f.folderName', ':name')) + ->setMaxResults(1) + ->setParameter(':name', StringHandler::slugify($folderName)); + + if (null !== $translation) { + $qb->addSelect('tf') + ->leftJoin('f.translatedFolders', 'tf') + ->andWhere($qb->expr()->eq('tf.translation', ':translation')) + ->setParameter(':translation', $translation); + } + + return $qb->getQuery()->getOneOrNullResult(); + } + + /** + * @param Folder $folder + * @return array + */ + public function findAllChildrenIdFromFolder(Folder $folder): array + { + $idsArray = $this->findChildrenIdFromFolder($folder); + + /** @var Folder $child */ + foreach ($folder->getChildren() as $child) { + $idsArray = array_merge($idsArray, $this->findAllChildrenIdFromFolder($child)); + } + + return $idsArray; + } + + /** + * @param Folder $folder + * @return array + */ + public function findChildrenIdFromFolder(Folder $folder): array + { + $qb = $this->createQueryBuilder('f'); + $qb->select('f.id') + ->where($qb->expr()->eq('f.parent', ':parent')) + ->setParameter(':parent', $folder); + + return array_map('current', $qb->getQuery()->getScalarResult()); + } + + /** + * Create a Criteria object from a search pattern and additionnal fields. + * + * @param string $pattern Search pattern + * @param QueryBuilder $qb QueryBuilder to pass + * @param array $criteria Additional criteria + * @param string $alias SQL query table alias + * @return QueryBuilder + */ + protected function createSearchBy( + string $pattern, + QueryBuilder $qb, + array &$criteria = [], + string $alias = "obj" + ): QueryBuilder { + $this->classicLikeComparison($pattern, $qb, $alias); + + /* + * Search in translations + */ + $qb->leftJoin('obj.translatedFolders', 'tf'); + $criteriaFields = []; + $metadata = $this->_em->getClassMetadata(FolderTranslation::class); + $cols = $metadata->getColumnNames(); + foreach ($cols as $col) { + $field = $metadata->getFieldName($col); + $type = $metadata->getTypeOfField($field); + if (in_array($type, $this->searchableTypes)) { + $criteriaFields[$field] = '%' . strip_tags((string) $pattern) . '%'; + } + } + foreach ($criteriaFields as $key => $value) { + $fullKey = sprintf('LOWER(%s)', 'tf.' . $key); + $qb->orWhere($qb->expr()->like($fullKey, $qb->expr()->literal($value))); + } + + $qb = $this->prepareComparisons($criteria, $qb, $alias); + + return $qb; + } + + /** + * @param string $pattern + * @param array $criteria + * @param string $alias + * @return int + * @throws \Doctrine\ORM\NoResultException + * @throws NonUniqueResultException + */ + public function countSearchBy(string $pattern, array $criteria = [], string $alias = "obj"): int + { + $qb = $this->createQueryBuilder($alias); + $qb->select($qb->expr()->countDistinct($alias)); + $qb = $this->createSearchBy($pattern, $qb, $criteria, $alias); + + return (int) $qb->getQuery()->getSingleScalarResult(); + } + + /** + * @param Document $document + * @param TranslationInterface|null $translation + * @return array + */ + public function findByDocumentAndTranslation(Document $document, TranslationInterface $translation = null): array + { + $qb = $this->createQueryBuilder('f'); + $qb->innerJoin('f.documents', 'd') + ->andWhere($qb->expr()->eq('d.id', ':documentId')) + ->setParameter(':documentId', $document->getId()); + + if (null !== $translation) { + $qb->addSelect('tf') + ->leftJoin( + 'f.translatedFolders', + 'tf', + Join::WITH, + 'tf.translation = :translation' + ) + ->setParameter(':translation', $translation); + } + + return $qb->getQuery()->getResult(); + } + + /** + * @param Folder|null $parent + * @param TranslationInterface|null $translation + * @return array + */ + public function findByParentAndTranslation(Folder $parent = null, TranslationInterface $translation = null): array + { + $qb = $this->createQueryBuilder('f'); + $qb->addOrderBy('f.position', 'ASC'); + + if (null === $parent) { + $qb->andWhere($qb->expr()->isNull('f.parent')); + } else { + $qb->andWhere($qb->expr()->eq('f.parent', ':parent')) + ->setParameter(':parent', $parent); + } + + if (null !== $translation) { + $qb->addSelect('tf') + ->leftJoin( + 'f.translatedFolders', + 'tf', + Join::WITH, + 'tf.translation = :translation' + ) + ->setParameter(':translation', $translation); + } + + return $qb->getQuery()->getResult(); + } +} diff --git a/lib/RoadizCoreBundle/src/Repository/FolderTranslationRepository.php b/lib/RoadizCoreBundle/src/Repository/FolderTranslationRepository.php new file mode 100644 index 00000000..3224fcfa --- /dev/null +++ b/lib/RoadizCoreBundle/src/Repository/FolderTranslationRepository.php @@ -0,0 +1,20 @@ + + */ +final class FolderTranslationRepository extends EntityRepository +{ + public function __construct(ManagerRegistry $registry, EventDispatcherInterface $dispatcher) + { + parent::__construct($registry, FolderTranslation::class, $dispatcher); + } +} diff --git a/lib/RoadizCoreBundle/src/Repository/GroupRepository.php b/lib/RoadizCoreBundle/src/Repository/GroupRepository.php new file mode 100644 index 00000000..d3660fd2 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Repository/GroupRepository.php @@ -0,0 +1,25 @@ + + */ +final class GroupRepository extends EntityRepository +{ + public function __construct(ManagerRegistry $registry, EventDispatcherInterface $dispatcher) + { + parent::__construct($registry, Group::class, $dispatcher); + } +} diff --git a/lib/RoadizCoreBundle/src/Repository/LogRepository.php b/lib/RoadizCoreBundle/src/Repository/LogRepository.php new file mode 100644 index 00000000..ac633d1f --- /dev/null +++ b/lib/RoadizCoreBundle/src/Repository/LogRepository.php @@ -0,0 +1,76 @@ + + */ +final class LogRepository extends EntityRepository +{ + public function __construct( + ManagerRegistry $registry, + EventDispatcherInterface $dispatcher + ) { + parent::__construct($registry, Log::class, $dispatcher); + } + + /** + * Find the latest Log with NodesSources. + * + * @param int $maxResult + * @return Paginator + */ + public function findLatestByNodesSources(int $maxResult = 5): Paginator + { + /* + * We need to split this query in 2 for performance matter. + * + * SELECT l1_.id, l1_.datetime, n0_.id + * FROM log AS l1_ + * INNER JOIN nodes_sources n0_ ON l1_.node_source_id = n0_.id + * WHERE l1_.id IN ( + * SELECT MAX(id) + * FROM log + * GROUP BY node_source_id + * ) + * ORDER BY l1_.datetime DESC + * LIMIT 8 + */ + + $subQb = $this->createQueryBuilder('slog'); + $subQb->select($subQb->expr()->max('slog.id')) + ->addGroupBy('slog.nodeSource'); + + $qb = $this->createQueryBuilder('log'); + $qb->select('log.id as id') + ->innerJoin('log.nodeSource', 'ns') + ->andWhere($qb->expr()->in('log.id', $subQb->getQuery()->getDQL())) + ->orderBy('log.datetime', 'DESC') + ->setMaxResults($maxResult) + ; + $ids = $qb->getQuery() + ->setHint(Query::HINT_FORCE_PARTIAL_LOAD, true) + ->getScalarResult(); + + $qb2 = $this->createQueryBuilder('log'); + $qb2->addSelect('ns, n, dbf') + ->andWhere($qb2->expr()->in('log.id', ':id')) + ->innerJoin('log.nodeSource', 'ns') + ->leftJoin('ns.documentsByFields', 'dbf') + ->innerJoin('ns.node', 'n') + ->orderBy('log.datetime', 'DESC') + ->setParameter(':id', array_map(function (array $item) { + return $item['id']; + }, $ids)); + + return new Paginator($qb2->getQuery(), true); + } +} diff --git a/lib/RoadizCoreBundle/src/Repository/LoginAttemptRepository.php b/lib/RoadizCoreBundle/src/Repository/LoginAttemptRepository.php new file mode 100644 index 00000000..377cb35c --- /dev/null +++ b/lib/RoadizCoreBundle/src/Repository/LoginAttemptRepository.php @@ -0,0 +1,139 @@ + + */ +final class LoginAttemptRepository extends EntityRepository +{ + public function __construct( + ManagerRegistry $registry, + EventDispatcherInterface $dispatcher + ) { + parent::__construct($registry, LoginAttempt::class, $dispatcher); + } + + /** + * @param string $username + * + * @return bool + * @throws \Doctrine\ORM\NoResultException + * @throws \Doctrine\ORM\NonUniqueResultException + */ + public function isUsernameBlocked(string $username): bool + { + $qb = $this->createQueryBuilder('la'); + return $qb->select('COUNT(la)') + ->andWhere($qb->expr()->gte('la.blocksLoginUntil', ':now')) + ->andWhere($qb->expr()->eq('la.username', ':username')) + ->getQuery() + ->setParameters([ + 'now' => new \DateTime('now'), + 'username' => $username, + ]) + ->getSingleScalarResult() > 0 + ; + } + + /** + * Checks if an IP address tries more than 10 usernames + * in the last 5 minutes. + * + * @param string $ipAddress + * @param int $seconds + * @param int $count + * + * @return bool + * @throws \Doctrine\ORM\NoResultException + * @throws \Doctrine\ORM\NonUniqueResultException + */ + public function isIpAddressBlocked(string $ipAddress, int $seconds = 1200, int $count = 10): bool + { + $qb = $this->createQueryBuilder('la'); + $query = $qb->select('SUM(la.attemptCount)') + ->andWhere($qb->expr()->gte('la.date', ':now')) + ->andWhere($qb->expr()->eq('la.ipAddress', ':ipAddress')) + ->getQuery() + ->setParameters([ + 'now' => (new \DateTime())->sub(new \DateInterval('PT' . $seconds . 'S')), + 'ipAddress' => $ipAddress, + ]) + ; + return $query->getSingleScalarResult() > $count ? true : false; + } + + /** + * @param string $ipAddress + * @param string $username + * + * @return LoginAttempt + * @throws \Doctrine\ORM\ORMException + */ + public function findOrCreateOneByIpAddressAndUsername(string $ipAddress, string $username): LoginAttempt + { + /** @var LoginAttempt|null $loginAttempt */ + $loginAttempt = $this->findOneBy([ + 'ipAddress' => $ipAddress, + 'username' => $username, + ]); + if (null === $loginAttempt) { + $loginAttempt = new LoginAttempt($ipAddress, $username); + $this->_em->persist($loginAttempt); + } + + return $loginAttempt; + } + + /** + * @param string $ipAddress + * @param string $username + */ + public function resetLoginAttempts(string $ipAddress, string $username): void + { + $qb = $this->_em->createQueryBuilder(); + $qb->delete(LoginAttempt::class, 'la') + ->andWhere($qb->expr()->eq('la.ipAddress', ':ipAddress')) + ->andWhere($qb->expr()->eq('la.username', ':username')) + ->getQuery() + ->execute([ + 'username' => $username, + 'ipAddress' => $ipAddress, + ]) + ; + } + + /** + * @param string $ipAddress + */ + public function purgeLoginAttempts(string $ipAddress): void + { + $qb = $this->_em->createQueryBuilder(); + $qb->delete(LoginAttempt::class, 'la') + ->andWhere($qb->expr()->eq('la.ipAddress', ':ipAddress')) + ->getQuery() + ->execute([ + 'ipAddress' => $ipAddress, + ]) + ; + } + + public function cleanLoginAttempts(): void + { + $qb = $this->_em->createQueryBuilder(); + $qb->delete(LoginAttempt::class, 'la') + ->andWhere($qb->expr()->lte('la.blocksLoginUntil', ':date')) + ->getQuery() + ->execute([ + 'date' => (new \DateTime())->sub(new \DateInterval('P1D')), + ]) + ; + } +} diff --git a/lib/RoadizCoreBundle/src/Repository/NodeRepository.php b/lib/RoadizCoreBundle/src/Repository/NodeRepository.php new file mode 100644 index 00000000..f90174df --- /dev/null +++ b/lib/RoadizCoreBundle/src/Repository/NodeRepository.php @@ -0,0 +1,1116 @@ + + */ +final class NodeRepository extends StatusAwareRepository +{ + public function __construct( + ManagerRegistry $registry, + PreviewResolverInterface $previewResolver, + EventDispatcherInterface $dispatcher, + Security $security + ) { + parent::__construct($registry, Node::class, $previewResolver, $dispatcher, $security); + } + + /** + * @param QueryBuilder $qb + * @param string $property + * @param mixed $value + * + * @return object|QueryBuilderBuildEvent + */ + protected function dispatchQueryBuilderBuildEvent(QueryBuilder $qb, string $property, mixed $value): object + { + return $this->dispatcher->dispatch( + new QueryBuilderBuildEvent($qb, Node::class, $property, $value, $this->getEntityName()) + ); + } + + /** + * @param QueryBuilder $qb + * @param string $property + * @param mixed $value + * + * @return object|QueryBuilderApplyEvent + */ + protected function dispatchQueryBuilderApplyEvent(QueryBuilder $qb, string $property, mixed $value): object + { + return $this->dispatcher->dispatch( + new QueryBuilderApplyEvent($qb, Node::class, $property, $value, $this->getEntityName()) + ); + } + + /** + * Just like the countBy method but with relational criteria. + * + * @param Criteria|mixed|array $criteria + * @param TranslationInterface|null $translation + * + * @return int + * @throws NonUniqueResultException + * @throws \Doctrine\ORM\NoResultException + */ + public function countBy( + mixed $criteria, + TranslationInterface $translation = null + ): int { + $qb = $this->createQueryBuilder(self::NODE_ALIAS); + $qb->select($qb->expr()->countDistinct(self::NODE_ALIAS)); + $qb->setMaxResults(1); + + if (null !== $translation) { + $this->filterByTranslation($criteria, $qb, $translation); + } + + /* + * Filtering by tag + */ + $this->filterByTag($criteria, $qb); + $this->filterByCriteria($criteria, $qb); + $this->alterQueryBuilderWithAuthorizationChecker($qb); + + $this->dispatchQueryBuilderEvent($qb, $this->getEntityName()); + $this->applyFilterByTag($criteria, $qb); + $this->applyFilterByCriteria($criteria, $qb); + $this->applyTranslationByTag($qb, $translation); + + return (int) $qb->getQuery()->getSingleScalarResult(); + } + + /** + * Create filters according to any translation criteria OR argument. + * + * @param array $criteria + * @param QueryBuilder $qb + * @param TranslationInterface|null $translation + */ + protected function filterByTranslation( + array $criteria, + QueryBuilder $qb, + TranslationInterface $translation = null + ): void { + if ( + isset($criteria['translation']) || + isset($criteria['translation.locale']) || + isset($criteria['translation.id']) || + isset($criteria['translation.available']) + ) { + $qb->innerJoin(self::NODE_ALIAS . '.nodeSources', self::NODESSOURCES_ALIAS); + $qb->innerJoin(self::NODESSOURCES_ALIAS . '.translation', self::TRANSLATION_ALIAS); + } else { + if (null !== $translation) { + /* + * With a given translation + */ + $qb->innerJoin( + 'n.nodeSources', + self::NODESSOURCES_ALIAS, + 'WITH', + self::NODESSOURCES_ALIAS . '.translation = :translation' + ); + } else { + /* + * With a null translation, not filter by translation to enable + * nodes with only one translation which is not the default one. + */ + $qb->innerJoin(self::NODE_ALIAS . '.nodeSources', self::NODESSOURCES_ALIAS); + } + } + } + + /** + * Add a tag filtering to queryBuilder. + * + * @param array $criteria + * @param QueryBuilder $qb + */ + protected function filterByTag(array &$criteria, QueryBuilder $qb): void + { + if (key_exists('tags', $criteria)) { + $this->buildTagFiltering($criteria, $qb); + } + } + + /** + * Reimplementing findBy features… with extra things. + * + * * key => array('<=', $value) + * * key => array('<', $value) + * * key => array('>=', $value) + * * key => array('>', $value) + * * key => array('BETWEEN', $value, $value) + * * key => array('LIKE', $value) + * * key => array('NOT IN', $array) + * * key => 'NOT NULL' + * + * You can filter with translations relation, examples: + * + * * `translation => $object` + * * `translation.locale => 'fr_FR'` + * + * @param array $criteria + * @param QueryBuilder $qb + */ + protected function filterByCriteria(array &$criteria, QueryBuilder $qb): void + { + $simpleQB = new SimpleQueryBuilder($qb); + /* + * Reimplementing findBy features… + */ + foreach ($criteria as $key => $value) { + if ($key == "tags" || $key == "tagExclusive") { + continue; + } + /* + * Main QueryBuilder dispatch loop for + * custom properties criteria. + */ + $event = $this->dispatchQueryBuilderBuildEvent($qb, $key, $value); + + if (!$event->isPropagationStopped()) { + /* + * compute prefix for + * filtering node, and sources relation fields + */ + $prefix = self::NODE_ALIAS . '.'; + // Dots are forbidden in field definitions + $baseKey = $simpleQB->getParameterKey($key); + $qb->andWhere($simpleQB->buildExpressionWithoutBinding($value, $prefix, $key, $baseKey)); + } + } + } + + /** + * Bind parameters to generated query. + * + * @param array $criteria + * @param QueryBuilder $qb + */ + protected function applyFilterByCriteria(array &$criteria, QueryBuilder $qb): void + { + /* + * Reimplementing findBy features… + */ + $simpleQB = new SimpleQueryBuilder($qb); + foreach ($criteria as $key => $value) { + if ($key == "tags" || $key == "tagExclusive") { + continue; + } + + $event = $this->dispatchQueryBuilderApplyEvent($qb, $key, $value); + if (!$event->isPropagationStopped()) { + $simpleQB->bindValue($key, $value); + } + } + } + + /** + * Bind translation parameter to final query. + * + * @param QueryBuilder $qb + * @param TranslationInterface|null $translation + */ + protected function applyTranslationByTag( + QueryBuilder $qb, + TranslationInterface $translation = null + ): void { + if (null !== $translation) { + $qb->setParameter('translation', $translation); + } + } + + /** + * Just like the findBy method but with a given Translation + * + * If no translation nor authorizationChecker is given, the vanilla `findBy` + * method will be called instead. + * + * @param array $criteria + * @param array|null $orderBy + * @param int|null $limit + * @param int|null $offset + * @param TranslationInterface|null $translation + * @return array|Paginator + */ + public function findByWithTranslation( + array $criteria, + array $orderBy = null, + ?int $limit = null, + ?int $offset = null, + TranslationInterface $translation = null + ): array|Paginator { + return $this->findBy( + $criteria, + $orderBy, + $limit, + $offset, + $translation + ); + } + + /** + * Just like the findBy method but with relational criteria. + * + * Reimplementing findBy features… with extra things: + * + * * key => array('<=', $value) + * * key => array('<', $value) + * * key => array('>=', $value) + * * key => array('>', $value) + * * key => array('BETWEEN', $value, $value) + * * key => array('LIKE', $value) + * * key => array('NOT IN', $array) + * * key => 'NOT NULL' + * + * You can filter with translations relation, examples: + * + * * `translation => $object` + * * `translation.locale => 'fr_FR'` + * + * Or filter by tags: + * + * * `tags => $tag1` + * * `tags => [$tag1, $tag2]` + * * `tags => [$tag1, $tag2], tagExclusive => true` + * + * @param array $criteria + * @param array|null $orderBy + * @param int|null $limit + * @param int|null $offset + * @param TranslationInterface|null $translation + * @return array|Paginator + */ + public function findBy( + array $criteria, + array $orderBy = null, + $limit = null, + $offset = null, + TranslationInterface $translation = null + ): array|Paginator { + $qb = $this->getContextualQueryWithTranslation( + $criteria, + $orderBy, + $limit, + $offset, + $translation + ); + + $qb->setCacheable(true); + $this->dispatchQueryBuilderEvent($qb, $this->getEntityName()); + $this->applyFilterByTag($criteria, $qb); + $this->applyFilterByCriteria($criteria, $qb); + $this->applyTranslationByTag($qb, $translation); + $query = $qb->getQuery(); + $this->dispatchQueryEvent($query); + + if ( + null !== $limit && + null !== $offset + ) { + /* + * We need to use Doctrine paginator + * if a limit is set because of the default inner join + */ + return new Paginator($query); + } else { + return $query->getResult(); + } + } + + /** + * Create a secureTranslationInterface query with node.published = true if user is + * not a Backend user and if authorizationChecker is defined. + * + * This method allows to pre-filter Nodes with a given translation. + * + * @param array $criteria + * @param array|null $orderBy + * @param int|null $limit + * @param int|null $offset + * @param TranslationInterface|null $translation + * @return QueryBuilder + */ + protected function getContextualQueryWithTranslation( + array &$criteria, + array $orderBy = null, + ?int $limit = null, + ?int $offset = null, + TranslationInterface $translation = null + ): QueryBuilder { + $qb = $this->createQueryBuilder(self::NODE_ALIAS); + $qb->addSelect(self::NODESSOURCES_ALIAS); + $this->filterByTranslation($criteria, $qb, $translation); + + /* + * Filtering by tag + */ + $this->filterByTag($criteria, $qb); + $this->filterByCriteria($criteria, $qb); + $this->alterQueryBuilderWithAuthorizationChecker($qb); + + // Add ordering + if (null !== $orderBy) { + foreach ($orderBy as $key => $value) { + if (str_starts_with($key, self::NODESSOURCES_ALIAS . '.')) { + $qb->addOrderBy($key, $value); + } elseif (str_starts_with($key, self::NODETYPE_ALIAS . '.')) { + if (!$this->hasJoinedNodeType($qb, self::NODE_ALIAS)) { + $qb->innerJoin(self::NODE_ALIAS . '.nodeType', self::NODETYPE_ALIAS); + } + $qb->addOrderBy($key, $value); + } else { + $qb->addOrderBy(self::NODE_ALIAS . '.' . $key, $value); + } + } + } + if (null !== $offset) { + $qb->setFirstResult($offset); + } + if (null !== $limit) { + $qb->setMaxResults($limit); + } + + return $qb; + } + + /** + * Just like the findOneBy method but with a given Translation and optional + * AuthorizationChecker. + * + * If no translation nor authorizationChecker is given, the vanilla `findOneBy` + * method will be called instead. + * + * @param array $criteria + * @param TranslationInterface|null $translation + * @return null|Node + * @throws NonUniqueResultException + */ + public function findOneByWithTranslation( + array $criteria, + TranslationInterface $translation = null + ): ?Node { + return $this->findOneBy( + $criteria, + null, + $translation + ); + } + + /** + * Just like the findOneBy method but with relational criteria. + * + * @param array $criteria + * @param array|null $orderBy + * @param TranslationInterface|null $translation + * @return null|Node + * @throws NonUniqueResultException + */ + public function findOneBy( + array $criteria, + array $orderBy = null, + TranslationInterface $translation = null + ): ?Node { + $qb = $this->getContextualQueryWithTranslation( + $criteria, + $orderBy, + 1, + 0, + $translation + ); + + $qb->setCacheable(true); + $this->dispatchQueryBuilderEvent($qb, $this->getEntityName()); + $this->applyFilterByTag($criteria, $qb); + $this->applyFilterByCriteria($criteria, $qb); + $this->applyTranslationByTag($qb, $translation); + $query = $qb->getQuery(); + $this->dispatchQueryEvent($query); + + return $query->getOneOrNullResult(); + } + + /** + * Find one Node with its Id and a given translation. + * + * @param int $nodeId + * @param TranslationInterface $translation + * @return null|Node + * @throws NonUniqueResultException + */ + public function findWithTranslation( + int $nodeId, + TranslationInterface $translation + ): ?Node { + $qb = $this->createQueryBuilder(self::NODE_ALIAS); + $qb->select('n, ns') + ->innerJoin('n.nodeSources', self::NODESSOURCES_ALIAS) + ->andWhere($qb->expr()->eq('n.id', ':nodeId')) + ->andWhere($qb->expr()->eq('ns.translation', ':translation')) + ->setMaxResults(1) + ->setParameter('nodeId', (int) $nodeId) + ->setParameter('translation', $translation) + ->setCacheable(true); + + $this->alterQueryBuilderWithAuthorizationChecker($qb); + + return $qb->getQuery()->getOneOrNullResult(); + } + + /** + * Find one Node with its Id and the default translation. + * + * @param int $nodeId + * @return null|Node + * @throws NonUniqueResultException + */ + public function findWithDefaultTranslation( + int $nodeId + ): ?Node { + $qb = $this->createQueryBuilder(self::NODE_ALIAS); + $qb->select('n, ns') + ->innerJoin('n.nodeSources', self::NODESSOURCES_ALIAS) + ->innerJoin('ns.translation', self::TRANSLATION_ALIAS) + ->andWhere($qb->expr()->eq('n.id', ':nodeId')) + ->andWhere($qb->expr()->eq('t.defaultTranslation', ':defaultTranslation')) + ->setMaxResults(1) + ->setParameter('nodeId', (int) $nodeId) + ->setParameter('defaultTranslation', true) + ->setCacheable(true); + + $this->alterQueryBuilderWithAuthorizationChecker($qb); + + return $qb->getQuery()->getOneOrNullResult(); + } + + /** + * Find one Node with its nodeName and a given translation. + * + * @param string $nodeName + * @param TranslationInterface $translation + * + * @return null|Node + * @throws NonUniqueResultException + * @deprecated Use findNodeTypeNameAndSourceIdByIdentifier + */ + public function findByNodeNameWithTranslation( + string $nodeName, + TranslationInterface $translation + ): ?Node { + $qb = $this->createQueryBuilder(self::NODE_ALIAS); + $qb->select('n, ns') + ->innerJoin('n.nodeSources', self::NODESSOURCES_ALIAS) + ->andWhere($qb->expr()->eq('n.nodeName', ':nodeName')) + ->andWhere($qb->expr()->eq('ns.translation', ':translation')) + ->setMaxResults(1) + ->setParameter('nodeName', $nodeName) + ->setParameter('translation', $translation) + ->setCacheable(true); + + $this->alterQueryBuilderWithAuthorizationChecker($qb); + + return $qb->getQuery()->getOneOrNullResult(); + } + + /** + * Find one node using its nodeName and a translation, or a unique URL alias. + * + * @param string $identifier + * @param TranslationInterface|null $translation + * @param bool $availableTranslation + * @param bool $allowNonReachableNodes + * @return array|null Array with node-type "name" and node-source "id" + * @throws NonUniqueResultException + */ + public function findNodeTypeNameAndSourceIdByIdentifier( + string $identifier, + ?TranslationInterface $translation, + bool $availableTranslation = false, + bool $allowNonReachableNodes = true + ): ?array { + $qb = $this->createQueryBuilder(self::NODE_ALIAS); + $qb->select('nt.name, ns.id') + ->innerJoin('n.nodeSources', self::NODESSOURCES_ALIAS) + ->innerJoin('n.nodeType', self::NODETYPE_ALIAS) + ->innerJoin('ns.translation', self::TRANSLATION_ALIAS) + ->leftJoin('ns.urlAliases', 'uas') + ->andWhere($qb->expr()->orX( + $qb->expr()->eq('uas.alias', ':identifier'), + $qb->expr()->andX( + $qb->expr()->eq('n.nodeName', ':identifier'), + $qb->expr()->eq('t.id', ':translation') + ) + )) + ->setParameter('identifier', $identifier) + ->setParameter('translation', $translation) + ->setMaxResults(1) + ->setCacheable(true); + + if (!$allowNonReachableNodes) { + $qb->andWhere($qb->expr()->eq('nt.reachable', ':reachable')) + ->setParameter('reachable', true); + } + + if ($availableTranslation) { + $qb->andWhere($qb->expr()->eq('t.available', ':available')) + ->setParameter('available', true); + } + + $this->alterQueryBuilderWithAuthorizationChecker($qb); + $query = $qb->getQuery(); + $query->setHint(Query::HINT_FORCE_PARTIAL_LOAD, true); + $query->setHydrationMode(Query::HYDRATE_ARRAY); + return $query->getOneOrNullResult(); + } + + /** + * Find one Node with its nodeName and the default translation. + * + * @param string $nodeName + * + * @return null|Node + * @throws NonUniqueResultException + * @deprecated Use findNodeTypeNameAndSourceIdByIdentifier + */ + public function findByNodeNameWithDefaultTranslation( + string $nodeName + ): ?Node { + $qb = $this->createQueryBuilder(self::NODE_ALIAS); + $qb->select('n, ns') + ->innerJoin('n.nodeSources', self::NODESSOURCES_ALIAS) + ->innerJoin('ns.translation', self::TRANSLATION_ALIAS) + ->andWhere($qb->expr()->eq('n.nodeName', ':nodeName')) + ->andWhere($qb->expr()->eq('t.defaultTranslation', ':defaultTranslation')) + ->setMaxResults(1) + ->setParameter('nodeName', $nodeName) + ->setParameter('defaultTranslation', true) + ->setCacheable(true); + + $this->alterQueryBuilderWithAuthorizationChecker($qb); + + return $qb->getQuery()->getOneOrNullResult(); + } + + /** + * Find the Home node with a given translation. + * + * @param TranslationInterface|null $translation + * @return null|Node + * @throws NonUniqueResultException + */ + public function findHomeWithTranslation( + TranslationInterface $translation = null + ): ?Node { + if (null === $translation) { + return $this->findHomeWithDefaultTranslation(); + } + + $qb = $this->createQueryBuilder(self::NODE_ALIAS); + $qb->select('n, ns') + ->innerJoin('n.nodeSources', self::NODESSOURCES_ALIAS) + ->andWhere($qb->expr()->eq('n.home', ':home')) + ->andWhere($qb->expr()->eq('ns.translation', ':translation')) + ->setMaxResults(1) + ->setParameter('home', true) + ->setParameter('translation', $translation) + ->setCacheable(true); + + $this->alterQueryBuilderWithAuthorizationChecker($qb); + + return $qb->getQuery()->getOneOrNullResult(); + } + + /** + * Find the Home node with the default translation. + * + * @return null|Node + * @throws NonUniqueResultException + */ + public function findHomeWithDefaultTranslation(): ?Node + { + $qb = $this->createQueryBuilder(self::NODE_ALIAS); + $qb->select('n, ns') + ->innerJoin('n.nodeSources', self::NODESSOURCES_ALIAS) + ->innerJoin('ns.translation', self::TRANSLATION_ALIAS) + ->andWhere($qb->expr()->eq('n.home', ':home')) + ->andWhere($qb->expr()->eq('t.defaultTranslation', ':defaultTranslation')) + ->setMaxResults(1) + ->setParameter('home', true) + ->setParameter('defaultTranslation', true) + ->setCacheable(true); + + $this->alterQueryBuilderWithAuthorizationChecker($qb); + + return $qb->getQuery()->getOneOrNullResult(); + } + + /** + * @param TranslationInterface $translation + * @param Node|null $parent + * @return array + */ + public function findByParentWithTranslation( + TranslationInterface $translation, + Node $parent = null + ): array { + $qb = $this->createQueryBuilder(self::NODE_ALIAS); + $qb->select('n, ns, ua') + ->innerJoin('n.nodeSources', self::NODESSOURCES_ALIAS) + ->leftJoin(self::NODESSOURCES_ALIAS . '.urlAliases', 'ua') + ->andWhere($qb->expr()->eq('ns.translation', ':translation')) + ->setParameter('translation', $translation) + ->addOrderBy('n.position', 'ASC') + ->setCacheable(true); + + if ($parent === null) { + $qb->andWhere($qb->expr()->isNull('n.parent')); + } else { + $qb->andWhere($qb->expr()->eq('n.parent', ':parent')) + ->setParameter('parent', $parent); + } + + $this->alterQueryBuilderWithAuthorizationChecker($qb); + + return $qb->getQuery()->getResult(); + } + + /** + * @param Node|null $parent + * @return Node[] + */ + public function findByParentWithDefaultTranslation(Node $parent = null): array + { + $qb = $this->createQueryBuilder(self::NODE_ALIAS); + $qb->select('n, ns') + ->innerJoin('n.nodeSources', self::NODESSOURCES_ALIAS) + ->innerJoin('ns.translation', self::TRANSLATION_ALIAS) + ->andWhere($qb->expr()->eq('t.defaultTranslation', true)) + ->addOrderBy('n.position', 'ASC') + ->setCacheable(true); + + if ($parent === null) { + $qb->andWhere($qb->expr()->isNull('n.parent')); + } else { + $qb->andWhere($qb->expr()->eq('n.parent', ':parent')) + ->setParameter('parent', $parent); + } + + $this->alterQueryBuilderWithAuthorizationChecker($qb); + + return $qb->getQuery()->getResult(); + } + + /** + * @param string $urlAliasAlias + * + * @return null|Node + * @throws NonUniqueResultException + */ + public function findOneWithAliasAndAvailableTranslation(string $urlAliasAlias): ?Node + { + $qb = $this->createQueryBuilder(self::NODE_ALIAS); + $qb->select('n, ns, t, uas') + ->innerJoin('n.nodeSources', self::NODESSOURCES_ALIAS) + ->innerJoin('ns.urlAliases', 'uas') + ->innerJoin('ns.translation', self::TRANSLATION_ALIAS) + ->andWhere($qb->expr()->eq('uas.alias', ':alias')) + ->andWhere($qb->expr()->eq('t.available', ':available')) + ->setParameter('alias', $urlAliasAlias) + ->setParameter('available', true) + ->setMaxResults(1) + ->setCacheable(true); + + $this->alterQueryBuilderWithAuthorizationChecker($qb); + + return $qb->getQuery()->getOneOrNullResult(); + } + + /** + * @param string $urlAliasAlias + * + * @return null|Node + * @throws NonUniqueResultException + */ + public function findOneWithAlias($urlAliasAlias): ?Node + { + $qb = $this->createQueryBuilder(self::NODE_ALIAS); + $qb->select('n, ns, t, uas') + ->innerJoin('n.nodeSources', self::NODESSOURCES_ALIAS) + ->innerJoin('ns.urlAliases', 'uas') + ->innerJoin('ns.translation', self::TRANSLATION_ALIAS) + ->andWhere($qb->expr()->eq('uas.alias', ':alias')) + ->setParameter('alias', $urlAliasAlias) + ->setMaxResults(1) + ->setCacheable(true); + + $this->alterQueryBuilderWithAuthorizationChecker($qb); + + return $qb->getQuery()->getOneOrNullResult(); + } + + /** + * @param string $nodeName + * + * @return bool + * @throws NonUniqueResultException|\Doctrine\ORM\NoResultException + */ + public function exists($nodeName): bool + { + $qb = $this->createQueryBuilder(self::NODE_ALIAS); + $qb->select($qb->expr()->countDistinct('n.nodeName')) + ->andWhere($qb->expr()->eq('n.nodeName', ':nodeName')) + ->setParameter('nodeName', $nodeName) + ->setMaxResults(1) + ; + + return (bool) $qb->getQuery()->getSingleScalarResult(); + } + + /** + * @param Node $node + * @param NodeTypeFieldInterface $field + * @return Node[] + */ + public function findByNodeAndField( + Node $node, + NodeTypeFieldInterface $field + ): array { + $qb = $this->createQueryBuilder(self::NODE_ALIAS); + $qb->select(self::NODE_ALIAS) + ->innerJoin('n.aNodes', 'ntn') + ->andWhere($qb->expr()->eq('ntn.field', ':field')) + ->andWhere($qb->expr()->eq('ntn.nodeA', ':nodeA')) + ->addOrderBy('ntn.position', 'ASC') + ->setCacheable(true); + + $this->alterQueryBuilderWithAuthorizationChecker($qb); + + $qb->setParameter('field', $field) + ->setParameter('nodeA', $node); + + return $qb->getQuery()->getResult(); + } + + /** + * @param Node $node + * @param NodeTypeFieldInterface $field + * @param TranslationInterface $translation + * @return array + */ + public function findByNodeAndFieldAndTranslation( + Node $node, + NodeTypeFieldInterface $field, + TranslationInterface $translation + ): array { + $qb = $this->createQueryBuilder(self::NODE_ALIAS); + $qb->select('n, ns') + ->innerJoin('n.aNodes', 'ntn') + ->innerJoin('n.nodeSources', self::NODESSOURCES_ALIAS) + ->andWhere($qb->expr()->eq('ntn.field', ':field')) + ->andWhere($qb->expr()->eq('ntn.nodeA', ':nodeA')) + ->andWhere($qb->expr()->eq('ns.translation', ':translation')) + ->addOrderBy('ntn.position', 'ASC') + ->setCacheable(true); + + $this->alterQueryBuilderWithAuthorizationChecker($qb); + + $qb->setParameter('field', $field) + ->setParameter('nodeA', $node) + ->setParameter('translation', $translation); + + return $qb->getQuery()->getResult(); + } + + /** + * @param Node $node + * @param NodeTypeFieldInterface $field + * @return array + */ + public function findByReverseNodeAndField( + Node $node, + NodeTypeFieldInterface $field + ): array { + $qb = $this->createQueryBuilder(self::NODE_ALIAS); + $qb->select(self::NODE_ALIAS) + ->innerJoin('n.bNodes', 'ntn') + ->andWhere($qb->expr()->eq('ntn.field', ':field')) + ->andWhere($qb->expr()->eq('ntn.nodeB', ':nodeB')) + ->addOrderBy('ntn.position', 'ASC') + ->setCacheable(true); + + $this->alterQueryBuilderWithAuthorizationChecker($qb); + + $qb->setParameter('field', $field) + ->setParameter('nodeB', $node); + + return $qb->getQuery()->getResult(); + } + + /** + * @param Node $node + * @param NodeTypeFieldInterface $field + * @param TranslationInterface $translation + * @return array + */ + public function findByReverseNodeAndFieldAndTranslation( + Node $node, + NodeTypeFieldInterface $field, + TranslationInterface $translation + ): array { + $qb = $this->createQueryBuilder(self::NODE_ALIAS); + $qb->select('n, ns') + ->innerJoin('n.bNodes', 'ntn') + ->innerJoin('n.nodeSources', self::NODESSOURCES_ALIAS) + ->andWhere($qb->expr()->eq('ntn.field', ':field')) + ->andWhere($qb->expr()->eq('ns.translation', ':translation')) + ->andWhere($qb->expr()->eq('ntn.nodeB', ':nodeB')) + ->addOrderBy('ntn.position', 'ASC') + ->setCacheable(true); + + $this->alterQueryBuilderWithAuthorizationChecker($qb); + + $qb->setParameter('field', $field) + ->setParameter('translation', $translation) + ->setParameter('nodeB', $node); + + return $qb->getQuery()->getResult(); + } + + /** + * @param Node $node + * @return array + */ + public function findAllOffspringIdByNode(Node $node) + { + $theOffprings = []; + $in = [$node->getId()]; + + do { + $theOffprings = array_merge($theOffprings, $in); + $subQb = $this->createQueryBuilder('n'); + $subQb->select('n.id') + ->andWhere($subQb->expr()->in('n.parent', ':tab')) + ->setParameter('tab', $in) + ->setCacheable(true); + $result = $subQb->getQuery()->getScalarResult(); + $in = []; + + //For memory optimizations + foreach ($result as $item) { + $in[] = (int) $item['id']; + } + } while (!empty($in)); + return $theOffprings; + } + + /** + * Find all node’ parents with criteria and ordering. + * + * @param Node $node + * @param array $criteria + * @param array|null $orderBy + * @param int|null $limit + * @param int|null $offset + * @param TranslationInterface|null $translation + * @return array|Paginator|null + */ + public function findAllNodeParentsBy( + Node $node, + array $criteria, + array $orderBy = null, + ?int $limit = null, + ?int $offset = null, + TranslationInterface $translation = null + ): array|Paginator|null { + $parentsId = $this->findAllParentsIdByNode($node); + if (count($parentsId) > 0) { + $criteria['id'] = $parentsId; + } else { + return null; + } + + return $this->findBy( + $criteria, + $orderBy, + $limit, + $offset, + $translation + ); + } + + public function findAllParentsIdByNode(Node $node): array + { + $theParents = []; + $parent = $node->getParent(); + + while (null !== $parent) { + $theParents[] = $parent->getId(); + $parent = $parent->getParent(); + } + + return $theParents; + } + + /** + * Create a Criteria object from a search pattern and additional fields. + * + * @param string $pattern Search pattern + * @param QueryBuilder $qb QueryBuilder to pass + * @param array $criteria Additional criteria + * @param string $alias SQL query table alias + * + * @return QueryBuilder + */ + protected function createSearchBy( + string $pattern, + QueryBuilder $qb, + array &$criteria = [], + string $alias = "obj" + ): QueryBuilder { + $this->classicLikeComparison($pattern, $qb, $alias); + + /* + * Search in translations + */ + $qb->innerJoin($alias . '.nodeSources', self::NODESSOURCES_ALIAS); + $criteriaFields = []; + $metadatas = $this->_em->getClassMetadata(NodesSources::class); + $cols = $metadatas->getColumnNames(); + foreach ($cols as $col) { + $field = $metadatas->getFieldName($col); + $type = $metadatas->getTypeOfField($field); + if (in_array($type, $this->searchableTypes)) { + $criteriaFields[$field] = '%' . strip_tags((string) $pattern) . '%'; + } + } + foreach ($criteriaFields as $key => $value) { + $fullKey = sprintf('LOWER(%s)', self::NODESSOURCES_ALIAS . '.' . $key); + $qb->orWhere($qb->expr()->like($fullKey, $qb->expr()->literal($value))); + } + + /* + * Handle Tag relational queries + */ + if (isset($criteria['tags'])) { + if ($criteria['tags'] instanceof PersistableInterface) { + $qb->innerJoin( + $alias . '.nodesTags', + 'ntg', + Expr\Join::WITH, + $qb->expr()->eq('ntg.tag', (int) $criteria['tags']->getId()) + ); + } elseif (is_array($criteria['tags'])) { + $qb->innerJoin( + $alias . '.nodesTags', + 'ntg', + Expr\Join::WITH, + $qb->expr()->in('ntg.tag', $criteria['tags']) + ); + } elseif (is_integer($criteria['tags'])) { + $qb->innerJoin( + $alias . '.nodesTags', + 'ntg', + Expr\Join::WITH, + $qb->expr()->eq('ntg.tag', (int) $criteria['tags']) + ); + } + unset($criteria['tags']); + } + + $this->prepareComparisons($criteria, $qb, $alias); + /* + * Alter at the end not to filter in OR groups + */ + $this->alterQueryBuilderWithAuthorizationChecker($qb, $alias); + + return $qb; + } + + protected function prepareComparisons(array &$criteria, QueryBuilder $qb, string $alias): QueryBuilder + { + $simpleQB = new SimpleQueryBuilder($qb); + foreach ($criteria as $key => $value) { + /* + * Main QueryBuilder dispatch loop for + * custom properties criteria. + */ + $event = $this->dispatchQueryBuilderBuildEvent($qb, $key, $value); + + if (!$event->isPropagationStopped()) { + $baseKey = $simpleQB->getParameterKey($key); + if ($key == 'translation') { + if (!$this->hasJoinedNodesSources($qb, $alias)) { + $qb->innerJoin($alias . '.nodeSources', self::NODESSOURCES_ALIAS); + } + $qb->andWhere($simpleQB->buildExpressionWithoutBinding( + $value, + self::NODESSOURCES_ALIAS . '.', + $key, + $baseKey + )); + } else { + $qb->andWhere($simpleQB->buildExpressionWithoutBinding( + $value, + $alias . '.', + $key, + $baseKey + )); + } + } + } + + return $qb; + } + + /** + * Get latest position in parent. + * + * Parent can be null for node root + * + * @param Node|null $parent + * @return int + * @throws NonUniqueResultException + * @throws NoResultException + */ + public function findLatestPositionInParent(Node $parent = null): int + { + $qb = $this->createQueryBuilder('n'); + $qb->select($qb->expr()->max('n.position')); + + if (null !== $parent) { + $qb->andWhere($qb->expr()->eq('n.parent', ':parent')) + ->setParameter(':parent', $parent); + } else { + $qb->andWhere($qb->expr()->isNull('n.parent')); + } + + return (int) $qb->getQuery()->getSingleScalarResult(); + } +} diff --git a/lib/RoadizCoreBundle/src/Repository/NodeTypeFieldRepository.php b/lib/RoadizCoreBundle/src/Repository/NodeTypeFieldRepository.php new file mode 100644 index 00000000..3e219678 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Repository/NodeTypeFieldRepository.php @@ -0,0 +1,109 @@ + + */ +final class NodeTypeFieldRepository extends EntityRepository +{ + public function __construct( + ManagerRegistry $registry, + EventDispatcherInterface $dispatcher + ) { + parent::__construct($registry, NodeTypeField::class, $dispatcher); + } + + /** + * @param NodeTypeInterface|null $nodeType + * @return array + */ + public function findAvailableGroupsForNodeType(?NodeTypeInterface $nodeType): array + { + if (null === $nodeType) { + return []; + } + $query = $this->_em->createQuery(' + SELECT partial ntf.{id,groupName} FROM RZ\Roadiz\CoreBundle\Entity\NodeTypeField ntf + WHERE ntf.visible = true + AND ntf.nodeType = :nodeType + GROUP BY ntf.groupName + ORDER BY ntf.groupName ASC + ')->setParameter(':nodeType', $nodeType); + + return $query->getScalarResult(); + } + + /** + * @param NodeTypeInterface|null $nodeType + * @return array + */ + public function findAllNotUniversal(?NodeTypeInterface $nodeType): array + { + if (null === $nodeType) { + return []; + } + $qb = $this->createQueryBuilder('ntf'); + $qb->andWhere($qb->expr()->eq('ntf.nodeType', ':nodeType')) + ->andWhere($qb->expr()->eq('ntf.universal', ':universal')) + ->orderBy('ntf.position', 'ASC') + ->setParameter(':nodeType', $nodeType) + ->setParameter(':universal', false); + + return $qb->getQuery()->getResult(); + } + + /** + * @param NodeTypeInterface|null $nodeType + * @return array + */ + public function findAllUniversal(?NodeTypeInterface $nodeType): array + { + if (null === $nodeType) { + return []; + } + $qb = $this->createQueryBuilder('ntf'); + $qb->andWhere($qb->expr()->eq('ntf.nodeType', ':nodeType')) + ->andWhere($qb->expr()->eq('ntf.universal', ':universal')) + ->orderBy('ntf.position', 'ASC') + ->setParameter(':nodeType', $nodeType) + ->setParameter(':universal', true); + + return $qb->getQuery()->getResult(); + } + + /** + * Get the latest position in nodeType. + * + * Parent can be null for tag root + * + * @param NodeTypeInterface|null $nodeType + * + * @return int + * @throws NoResultException + * @throws NonUniqueResultException + */ + public function findLatestPositionInNodeType(?NodeTypeInterface $nodeType): int + { + if (null === $nodeType) { + return 0; + } + $query = $this->_em->createQuery(' + SELECT MAX(ntf.position) + FROM RZ\Roadiz\CoreBundle\Entity\NodeTypeField ntf + WHERE ntf.nodeType = :nodeType') + ->setParameter('nodeType', $nodeType); + + return (int) $query->getSingleScalarResult(); + } +} diff --git a/lib/RoadizCoreBundle/src/Repository/NodeTypeRepository.php b/lib/RoadizCoreBundle/src/Repository/NodeTypeRepository.php new file mode 100644 index 00000000..5b15f4d3 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Repository/NodeTypeRepository.php @@ -0,0 +1,36 @@ + + */ +final class NodeTypeRepository extends EntityRepository +{ + public function __construct( + ManagerRegistry $registry, + EventDispatcherInterface $dispatcher + ) { + parent::__construct($registry, NodeType::class, $dispatcher); + } + /** + * @return array + */ + public function findAll(): array + { + $qb = $this->createQueryBuilder('nt'); + $qb->addSelect('ntf') + ->leftJoin('nt.fields', 'ntf') + ->addOrderBy('nt.name', 'ASC') + ->setCacheable(true); + + return $qb->getQuery()->getResult(); + } +} diff --git a/lib/RoadizCoreBundle/src/Repository/NodesCustomFormsRepository.php b/lib/RoadizCoreBundle/src/Repository/NodesCustomFormsRepository.php new file mode 100644 index 00000000..dc41cec0 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Repository/NodesCustomFormsRepository.php @@ -0,0 +1,41 @@ + + */ +final class NodesCustomFormsRepository extends EntityRepository +{ + public function __construct( + ManagerRegistry $registry, + EventDispatcherInterface $dispatcher + ) { + parent::__construct($registry, NodesCustomForms::class, $dispatcher); + } + /** + * @param Node $node + * @param NodeTypeField $field + * + * @return integer + */ + public function getLatestPosition(Node $node, NodeTypeField $field) + { + $query = $this->_em->createQuery(' + SELECT MAX(ncf.position) FROM RZ\Roadiz\CoreBundle\Entity\NodesCustomForms ncf + WHERE ncf.node = :node AND ncf.field = :field') + ->setParameter('node', $node) + ->setParameter('field', $field); + + return (int) $query->getSingleScalarResult(); + } +} diff --git a/lib/RoadizCoreBundle/src/Repository/NodesSourcesDocumentsRepository.php b/lib/RoadizCoreBundle/src/Repository/NodesSourcesDocumentsRepository.php new file mode 100644 index 00000000..0c24dc5d --- /dev/null +++ b/lib/RoadizCoreBundle/src/Repository/NodesSourcesDocumentsRepository.php @@ -0,0 +1,44 @@ + + */ +final class NodesSourcesDocumentsRepository extends EntityRepository +{ + public function __construct( + ManagerRegistry $registry, + EventDispatcherInterface $dispatcher + ) { + parent::__construct($registry, NodesSourcesDocuments::class, $dispatcher); + } + + /** + * @param NodesSources $nodeSource + * @param NodeTypeField $field + * @return int + * @throws NoResultException + * @throws NonUniqueResultException + */ + public function getLatestPosition(NodesSources $nodeSource, NodeTypeField $field): int + { + $query = $this->_em->createQuery(' + SELECT MAX(nsd.position) FROM RZ\Roadiz\CoreBundle\Entity\NodesSourcesDocuments nsd + WHERE nsd.nodeSource = :nodeSource AND nsd.field = :field') + ->setParameter('nodeSource', $nodeSource) + ->setParameter('field', $field); + + return (int) $query->getSingleScalarResult(); + } +} diff --git a/lib/RoadizCoreBundle/src/Repository/NodesSourcesRepository.php b/lib/RoadizCoreBundle/src/Repository/NodesSourcesRepository.php new file mode 100644 index 00000000..b3dab4fe --- /dev/null +++ b/lib/RoadizCoreBundle/src/Repository/NodesSourcesRepository.php @@ -0,0 +1,755 @@ + + * @template-extends StatusAwareRepository + */ +class NodesSourcesRepository extends StatusAwareRepository +{ + private ?NodeSourceSearchHandlerInterface $nodeSourceSearchHandler; + + /** + * @param ManagerRegistry $registry + * @param PreviewResolverInterface $previewResolver + * @param EventDispatcherInterface $dispatcher + * @param Security $security + * @param NodeSourceSearchHandlerInterface|null $nodeSourceSearchHandler + */ + public function __construct( + ManagerRegistry $registry, + PreviewResolverInterface $previewResolver, + EventDispatcherInterface $dispatcher, + Security $security, + ?NodeSourceSearchHandlerInterface $nodeSourceSearchHandler + ) { + parent::__construct($registry, NodesSources::class, $previewResolver, $dispatcher, $security); + $this->nodeSourceSearchHandler = $nodeSourceSearchHandler; + } + + /** + * @param QueryBuilder $qb + * @param string $property + * @param mixed $value + * + * @return object|QueryBuilderNodesSourcesBuildEvent + */ + protected function dispatchQueryBuilderBuildEvent(QueryBuilder $qb, string $property, mixed $value): object + { + return $this->dispatcher->dispatch( + new QueryBuilderNodesSourcesBuildEvent($qb, $property, $value, $this->getEntityName()) + ); + } + + /** + * @param QueryBuilder $qb + * @param string $property + * @param mixed $value + * + * @return object|QueryBuilderNodesSourcesApplyEvent + */ + protected function dispatchQueryBuilderApplyEvent(QueryBuilder $qb, string $property, mixed $value): object + { + return $this->dispatcher->dispatch( + new QueryBuilderNodesSourcesApplyEvent($qb, $property, $value, $this->getEntityName()) + ); + } + + /** + * @param Query $query + * + * @return object|QueryNodesSourcesEvent + */ + protected function dispatchQueryEvent(Query $query): object + { + return $this->dispatcher->dispatch( + new QueryNodesSourcesEvent($query, $this->getEntityName()) + ); + } + + /** + * Add a tag filtering to queryBuilder. + * + * @param array $criteria + * @param QueryBuilder $qb + */ + protected function filterByTag(array &$criteria, QueryBuilder $qb): void + { + if (key_exists('tags', $criteria)) { + if (!$this->hasJoinedNode($qb, static::NODESSOURCES_ALIAS)) { + $qb->innerJoin( + static::NODESSOURCES_ALIAS . '.node', + static::NODE_ALIAS + ); + } + + $this->buildTagFiltering($criteria, $qb); + } + } + + /** + * Reimplementing findBy features… with extra things. + * + * * key => array('<=', $value) + * * key => array('<', $value) + * * key => array('>=', $value) + * * key => array('>', $value) + * * key => array('BETWEEN', $value, $value) + * * key => array('LIKE', $value) + * * key => array('NOT IN', $array) + * * key => 'NOT NULL' + * + * You even can filter with node fields, examples: + * + * * `node.published => true` + * * `node.nodeName => 'page1'` + * + * @param array $criteria + * @param QueryBuilder $qb + */ + protected function filterByCriteria( + array &$criteria, + QueryBuilder $qb + ): void { + $simpleQB = new SimpleQueryBuilder($qb); + /* + * Reimplementing findBy features… + */ + foreach ($criteria as $key => $value) { + if ($key == "tags" || $key == "tagExclusive") { + continue; + } + /* + * Main QueryBuilder dispatch loop for + * custom properties criteria. + */ + $event = $this->dispatchQueryBuilderBuildEvent($qb, $key, $value); + + if (!$event->isPropagationStopped()) { + /* + * compute prefix for + * filtering node relation fields + */ + $prefix = static::NODESSOURCES_ALIAS . '.'; + // Dots are forbidden in field definitions + $baseKey = $simpleQB->getParameterKey($key); + $qb->andWhere($simpleQB->buildExpressionWithoutBinding($value, $prefix, $key, $baseKey)); + } + } + } + + /** + * Bind parameters to generated query. + * + * @param array $criteria + * @param QueryBuilder $qb + */ + protected function applyFilterByCriteria(array &$criteria, QueryBuilder $qb): void + { + /* + * Reimplementing findBy features… + */ + $simpleQB = new SimpleQueryBuilder($qb); + foreach ($criteria as $key => $value) { + if ($key == "tags" || $key == "tagExclusive") { + continue; + } + + $event = $this->dispatchQueryBuilderApplyEvent($qb, $key, $value); + if (!$event->isPropagationStopped()) { + $simpleQB->bindValue($key, $value); + } + } + } + + + /** + * @param QueryBuilder $qb + * @param string $prefix + * @return QueryBuilder + */ + public function alterQueryBuilderWithAuthorizationChecker( + QueryBuilder $qb, + string $prefix = EntityRepository::NODESSOURCES_ALIAS + ): QueryBuilder { + if (true === $this->isDisplayingAllNodesStatuses()) { + if (!$this->hasJoinedNode($qb, $prefix)) { + $qb->innerJoin($prefix . '.node', static::NODE_ALIAS); + } + return $qb; + } + + if (true === $this->isDisplayingNotPublishedNodes() || $this->previewResolver->isPreview()) { + /* + * Forbid deleted node for backend user when authorizationChecker not null. + */ + if (!$this->hasJoinedNode($qb, $prefix)) { + $qb->innerJoin( + $prefix . '.node', + static::NODE_ALIAS, + 'WITH', + $qb->expr()->lte(static::NODE_ALIAS . '.status', Node::PUBLISHED) + ); + } else { + $qb->andWhere($qb->expr()->lte(static::NODE_ALIAS . '.status', Node::PUBLISHED)); + } + } else { + /* + * Forbid unpublished node for anonymous and not backend users. + */ + if (!$this->hasJoinedNode($qb, $prefix)) { + $qb->innerJoin( + $prefix . '.node', + static::NODE_ALIAS, + 'WITH', + $qb->expr()->eq(static::NODE_ALIAS . '.status', Node::PUBLISHED) + ); + } else { + $qb->andWhere($qb->expr()->eq(static::NODE_ALIAS . '.status', Node::PUBLISHED)); + } + } + return $qb; + } + + /** + * Create a secure query with node.published = true if user is + * not a Backend user. + * + * @param array $criteria + * @param array|null $orderBy + * @param integer|null $limit + * @param integer|null $offset + * @return QueryBuilder + */ + protected function getContextualQuery( + array &$criteria, + array $orderBy = null, + $limit = null, + $offset = null + ) { + $qb = $this->createQueryBuilder(static::NODESSOURCES_ALIAS); + $this->alterQueryBuilderWithAuthorizationChecker($qb, static::NODESSOURCES_ALIAS); + $qb->addSelect(static::NODE_ALIAS); + /* + * Filtering by tag + */ + $this->filterByTag($criteria, $qb); + $this->filterByCriteria($criteria, $qb); + + // Add ordering + if (null !== $orderBy) { + foreach ($orderBy as $key => $value) { + if (false !== strpos($key, 'node.')) { + $simpleKey = str_replace('node.', '', $key); + $qb->addOrderBy(static::NODE_ALIAS . '.' . $simpleKey, $value); + } else { + $qb->addOrderBy(static::NODESSOURCES_ALIAS . '.' . $key, $value); + } + } + } + + if (null !== $offset) { + $qb->setFirstResult($offset); + } + if (null !== $limit) { + $qb->setMaxResults($limit); + } + + return $qb; + } + + /** + * Create a secured count query with node.published = true if user is + * not a Backend user and if authorizationChecker is defined. + * + * This method allows to pre-filter Nodes with a given translation. + * + * @param array $criteria + * @return QueryBuilder + */ + protected function getCountContextualQuery(array &$criteria) + { + $qb = $this->createQueryBuilder(static::NODESSOURCES_ALIAS); + $this->alterQueryBuilderWithAuthorizationChecker($qb, static::NODESSOURCES_ALIAS); + /* + * Filtering by tag + */ + $this->filterByTag($criteria, $qb); + $this->filterByCriteria($criteria, $qb); + + return $qb->select($qb->expr()->countDistinct(static::NODESSOURCES_ALIAS . '.id')); + } + + /** + * Just like the countBy method but with relational criteria. + * + * @param array $criteria + * + * @return int + * @throws \Doctrine\ORM\NoResultException + * @throws \Doctrine\ORM\NonUniqueResultException + */ + public function countBy(mixed $criteria): int + { + $query = $this->getCountContextualQuery($criteria); + $this->dispatchQueryBuilderEvent($query, $this->getEntityName()); + $this->applyFilterByTag($criteria, $query); + $this->applyFilterByCriteria($criteria, $query); + + return (int) $query->getQuery()->getSingleScalarResult(); + } + + /** + * A secure findBy with which user must be a backend user + * to see unpublished nodes. + * + * Reimplementing findBy features with extra things. + * + * * key => array('<=', $value) + * * key => array('<', $value) + * * key => array('>=', $value) + * * key => array('>', $value) + * * key => array('BETWEEN', $value, $value) + * * key => array('LIKE', $value) + * * key => array('NOT IN', $array) + * * key => 'NOT NULL' + * + * You even can filter with node fields, examples: + * + * * `node.published => true` + * * `node.nodeName => 'page1'` + * + * Or filter by tags: + * + * * `tags => $tag1` + * * `tags => [$tag1, $tag2]` + * * `tags => [$tag1, $tag2], tagExclusive => true` + * + * @param array $criteria + * @param array|null $orderBy + * @param int|null $limit + * @param int|null $offset + * @return array|Paginator + */ + public function findBy( + array $criteria, + array $orderBy = null, + $limit = null, + $offset = null + ) { + $qb = $this->getContextualQuery( + $criteria, + $orderBy, + $limit, + $offset + ); + /* + * Eagerly fetch UrlAliases + * to limit SQL query count + */ + $qb->leftJoin(static::NODESSOURCES_ALIAS . '.urlAliases', 'ua') + ->addSelect('ua') + ; + $qb->setCacheable(true); + $this->dispatchQueryBuilderEvent($qb, $this->getEntityName()); + $this->applyFilterByTag($criteria, $qb); + $this->applyFilterByCriteria($criteria, $qb); + $query = $qb->getQuery(); + $this->dispatchQueryEvent($query); + + if ( + null !== $limit && + null !== $offset + ) { + /* + * We need to use Doctrine paginator + * if a limit is set because of the default inner join + */ + return new Paginator($query); + } else { + return $query->getResult(); + } + } + + /** + * A secure findOneBy with which user must be a backend user + * to see unpublished nodes. + * + * @param array $criteria + * @param array|null $orderBy + * + * @return null|NodesSources + * @throws \Doctrine\ORM\NonUniqueResultException + */ + public function findOneBy( + array $criteria, + array $orderBy = null + ) { + $qb = $this->getContextualQuery( + $criteria, + $orderBy, + 1, + null + ); + /* + * Eagerly fetch UrlAliases + * to limit SQL query count + */ + $qb->leftJoin(static::NODESSOURCES_ALIAS . '.urlAliases', 'ua') + ->addSelect('ua') + ; + $qb->setCacheable(true); + $this->dispatchQueryBuilderEvent($qb, $this->getEntityName()); + $this->applyFilterByTag($criteria, $qb); + $this->applyFilterByCriteria($criteria, $qb); + $query = $qb->getQuery(); + $this->dispatchQueryEvent($query); + + return $query->getOneOrNullResult(); + } + + /** + * Search nodes sources by using Solr search engine. + * + * @param string $query Solr query string (for example: `text:Lorem Ipsum`) + * @param int $limit Result number to fetch (default: all) + * @return array + */ + public function findBySearchQuery(string $query, int $limit = 25): array + { + if (null !== $this->nodeSourceSearchHandler) { + try { + $this->nodeSourceSearchHandler->boostByUpdateDate(); + $arguments = []; + if ($this->isDisplayingNotPublishedNodes()) { + $arguments['status'] = ['<=', Node::PUBLISHED]; + } + if ($this->isDisplayingAllNodesStatuses()) { + $arguments['status'] = ['<=', Node::DELETED]; + } + + if ($limit > 0) { + return $this->nodeSourceSearchHandler->search($query, $arguments, $limit)->getResultItems(); + } + return $this->nodeSourceSearchHandler->search($query, $arguments, 999999)->getResultItems(); + } catch (SolrServerNotAvailableException $exception) { + return []; + } + } + return []; + } + + /** + * Search nodes sources by using Solr search engine + * and a specific translation. + * + * @param string $query Solr query string (for example: `text:Lorem Ipsum`) + * @param TranslationInterface $translation Current translation + * @param int $limit + * @return SearchResultsInterface + */ + public function findBySearchQueryAndTranslation($query, TranslationInterface $translation, $limit = 25) + { + if (null !== $this->nodeSourceSearchHandler) { + try { + $params = [ + 'translation' => $translation, + ]; + + if ($limit > 0) { + return $this->nodeSourceSearchHandler->search($query, $params, $limit); + } else { + return $this->nodeSourceSearchHandler->search($query, $params, 999999); + } + } catch (SolrServerNotAvailableException $exception) { + return new SolrSearchResults([], $this->_em); + } + } + return new SolrSearchResults([], $this->_em); + } + + /** + * Search Nodes-Sources using LIKE condition on title + * meta-title, meta-keywords, meta-description. + * + * @param string $textQuery + * @param int $limit + * @param array $nodeTypes + * @param bool $onlyVisible + * @param array $additionalCriteria + * @return array + */ + public function findByTextQuery( + string $textQuery, + int $limit = 0, + array $nodeTypes = [], + bool $onlyVisible = false, + array $additionalCriteria = [] + ) { + $qb = $this->createQueryBuilder(static::NODESSOURCES_ALIAS); + $qb->addSelect(static::NODE_ALIAS) + ->addSelect('ua') + ->leftJoin(static::NODESSOURCES_ALIAS . '.urlAliases', 'ua') + ->andWhere($qb->expr()->orX( + $qb->expr()->like(static::NODESSOURCES_ALIAS . '.title', ':query'), + $qb->expr()->like(static::NODESSOURCES_ALIAS . '.metaTitle', ':query'), + $qb->expr()->like(static::NODESSOURCES_ALIAS . '.metaKeywords', ':query'), + $qb->expr()->like(static::NODESSOURCES_ALIAS . '.metaDescription', ':query') + )) + ->orderBy(static::NODESSOURCES_ALIAS . '.title', 'ASC') + ->setParameter(':query', '%' . $textQuery . '%'); + + if ($limit > 0) { + $qb->setMaxResults($limit); + } + + /* + * Alteration always join node table. + */ + $this->alterQueryBuilderWithAuthorizationChecker($qb, static::NODESSOURCES_ALIAS); + + if (count($nodeTypes) > 0) { + $additionalCriteria['node.nodeType'] = $nodeTypes; + } + + if (true === $onlyVisible) { + $additionalCriteria['node.visible'] = true; + } + + $this->dispatchQueryBuilderEvent($qb, $this->getEntityName()); + + if (count($additionalCriteria) > 0) { + $this->prepareComparisons($additionalCriteria, $qb, static::NODESSOURCES_ALIAS); + $this->applyFilterByCriteria($additionalCriteria, $qb); + } + + $query = $qb->getQuery(); + $this->dispatchQueryEvent($query); + + return $query->getResult(); + } + + /** + * Find latest updated NodesSources using Log table. + * + * @param int $maxResult + * @return Paginator + */ + public function findByLatestUpdated($maxResult = 5) + { + $subQuery = $this->_em->createQueryBuilder(); + $subQuery->select('sns.id') + ->from(Log::class, 'slog') + ->innerJoin(NodesSources::class, 'sns') + ->andWhere($subQuery->expr()->isNotNull('slog.nodeSource')) + ->orderBy('slog.datetime', 'DESC'); + + $query = $this->createQueryBuilder(static::NODESSOURCES_ALIAS); + $query->andWhere($query->expr()->in(static::NODESSOURCES_ALIAS . '.id', $subQuery->getQuery()->getDQL())); + $query->setMaxResults($maxResult); + + return new Paginator($query->getQuery()); + } + + /** + * Get node-source parent according to its translation. + * + * @param NodesSources $nodeSource + * + * @return NodesSources|null + * @throws \Doctrine\ORM\NonUniqueResultException + */ + public function findParent(NodesSources $nodeSource) + { + $qb = $this->createQueryBuilder(static::NODESSOURCES_ALIAS); + $qb->select(static::NODESSOURCES_ALIAS . ', n, ua') + ->innerJoin(static::NODESSOURCES_ALIAS . '.node', static::NODE_ALIAS) + ->innerJoin('n.children', 'cn') + ->leftJoin(static::NODESSOURCES_ALIAS . '.urlAliases', 'ua') + ->andWhere($qb->expr()->eq('cn.id', ':childNodeId')) + ->andWhere($qb->expr()->eq(static::NODESSOURCES_ALIAS . '.translation', ':translation')) + ->setParameter('childNodeId', $nodeSource->getNode()->getId()) + ->setParameter('translation', $nodeSource->getTranslation()) + ->setMaxResults(1) + ->setCacheable(true); + + return $qb->getQuery()->getOneOrNullResult(); + } + + /** + * @param Node $node + * @param TranslationInterface|null $translation + * @return mixed|null + */ + public function findOneByNodeAndTranslation(Node $node, ?TranslationInterface $translation) + { + $qb = $this->createQueryBuilder(static::NODESSOURCES_ALIAS); + + $qb->select(static::NODESSOURCES_ALIAS) + ->andWhere($qb->expr()->eq(static::NODESSOURCES_ALIAS . '.node', ':node')) + ->setMaxResults(1) + ->setParameter('node', $node) + ->setCacheable(true); + + if (null !== $translation) { + $qb->andWhere($qb->expr()->eq(static::NODESSOURCES_ALIAS . '.translation', ':translation')) + ->setParameter('translation', $translation); + } + + return $qb->getQuery()->getOneOrNullResult(); + } + + /** + * @inheritdoc + * + * Extends EntityRepository to make join possible with «node.» prefix. + * Required if making search with EntityListManager and filtering by node criteria. + */ + protected function prepareComparisons(array &$criteria, QueryBuilder $qb, $alias) + { + $simpleQB = new SimpleQueryBuilder($qb); + + foreach ($criteria as $key => $value) { + /* + * Main QueryBuilder dispatch loop for + * custom properties criteria. + */ + $event = $this->dispatchQueryBuilderBuildEvent($qb, $key, $value); + + if (!$event->isPropagationStopped()) { + $baseKey = $simpleQB->getParameterKey($key); + if (false !== strpos($key, 'node.nodeType.')) { + if (!$this->hasJoinedNode($qb, $alias)) { + $qb->innerJoin($alias . '.node', static::NODE_ALIAS); + } + if (!$this->hasJoinedNodeType($qb, $alias)) { + $qb->innerJoin(static::NODE_ALIAS . '.nodeType', static::NODETYPE_ALIAS); + } + $prefix = static::NODETYPE_ALIAS . '.'; + $simpleKey = str_replace('node.nodeType.', '', $key); + $qb->andWhere($simpleQB->buildExpressionWithoutBinding($value, $prefix, $simpleKey, $baseKey)); + } elseif (false !== strpos($key, 'node.')) { + if (!$this->hasJoinedNode($qb, $alias)) { + $qb->innerJoin($alias . '.node', static::NODE_ALIAS); + } + $prefix = static::NODE_ALIAS . '.'; + $simpleKey = str_replace('node.', '', $key); + $qb->andWhere($simpleQB->buildExpressionWithoutBinding($value, $prefix, $simpleKey, $baseKey)); + } else { + $qb->andWhere($simpleQB->buildExpressionWithoutBinding($value, $alias . '.', $key, $baseKey)); + } + } + } + + return $qb; + } + + /** + * @inheritDoc + */ + protected function createSearchBy( + string $pattern, + QueryBuilder $qb, + array &$criteria = [], + string $alias = EntityRepository::DEFAULT_ALIAS + ): QueryBuilder { + $qb = parent::createSearchBy($pattern, $qb, $criteria, $alias); + $this->alterQueryBuilderWithAuthorizationChecker($qb, $alias); + + return $qb; + } + + /** + * @inheritDoc + */ + public function searchBy( + string $pattern, + array $criteria = [], + array $orders = [], + $limit = null, + $offset = null, + string $alias = EntityRepository::DEFAULT_ALIAS + ): array|Paginator { + return parent::searchBy($pattern, $criteria, $orders, $limit, $offset, static::NODESSOURCES_ALIAS); + } + + /** + * @param NodesSources $nodesSources + * @param NodeTypeFieldInterface $field + * + * @return array|null + */ + public function findByNodesSourcesAndFieldAndTranslation( + NodesSources $nodesSources, + NodeTypeFieldInterface $field + ): ?array { + $qb = $this->createQueryBuilder(static::NODESSOURCES_ALIAS); + $qb->select('ns, n, ua') + ->innerJoin('ns.node', static::NODE_ALIAS) + ->leftJoin('ns.urlAliases', 'ua') + ->innerJoin('n.aNodes', 'ntn') + ->andWhere($qb->expr()->eq('ntn.field', ':field')) + ->andWhere($qb->expr()->eq('ntn.nodeA', ':nodeA')) + ->andWhere($qb->expr()->eq('ns.translation', ':translation')) + ->addOrderBy('ntn.position', 'ASC') + ->setCacheable(true); + + $this->alterQueryBuilderWithAuthorizationChecker($qb); + + $qb->setParameter('field', $field) + ->setParameter('nodeA', $nodesSources->getNode()) + ->setParameter('translation', $nodesSources->getTranslation()); + + return $qb->getQuery()->getResult(); + } + + public function findByNode(Node $node): array + { + $qb = $this->createQueryBuilder(static::NODESSOURCES_ALIAS); + $qb->select('ns, n, ua') + ->innerJoin('ns.node', static::NODE_ALIAS) + ->innerJoin('ns.translation', static::TRANSLATION_ALIAS) + ->leftJoin('ns.urlAliases', 'ua') + ->andWhere($qb->expr()->eq('n.id', ':node')) + ->addOrderBy(static::TRANSLATION_ALIAS . '.defaultTranslation', 'DESC') + ->addOrderBy(static::TRANSLATION_ALIAS . '.locale', 'ASC') + ->setParameter('node', $node) + ->setCacheable(true); + + $this->alterQueryBuilderWithAuthorizationChecker($qb); + if (!$this->previewResolver->isPreview()) { + $qb->andWhere($qb->expr()->eq(static::TRANSLATION_ALIAS . '.available', ':available')) + ->setParameter('available', true); + } + + return $qb->getQuery()->getResult(); + } +} diff --git a/lib/RoadizCoreBundle/src/Repository/NodesToNodesRepository.php b/lib/RoadizCoreBundle/src/Repository/NodesToNodesRepository.php new file mode 100644 index 00000000..a64042a3 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Repository/NodesToNodesRepository.php @@ -0,0 +1,46 @@ + + */ +final class NodesToNodesRepository extends EntityRepository +{ + public function __construct( + ManagerRegistry $registry, + EventDispatcherInterface $dispatcher + ) { + parent::__construct($registry, NodesToNodes::class, $dispatcher); + } + + /** + * @param Node $node + * @param NodeTypeField $field + * + * @return integer + * @throws NoResultException + * @throws NonUniqueResultException + */ + public function getLatestPosition(Node $node, NodeTypeField $field): int + { + $query = $this->_em->createQuery(' + SELECT MAX(ntn.position) FROM RZ\Roadiz\CoreBundle\Entity\NodesToNodes ntn + WHERE ntn.nodeA = :nodeA AND ntn.field = :field') + ->setParameter('nodeA', $node) + ->setParameter('field', $field); + + return (int) $query->getSingleScalarResult(); + } +} diff --git a/lib/RoadizCoreBundle/src/Repository/PrefixAwareRepository.php b/lib/RoadizCoreBundle/src/Repository/PrefixAwareRepository.php new file mode 100644 index 00000000..d07f127a --- /dev/null +++ b/lib/RoadizCoreBundle/src/Repository/PrefixAwareRepository.php @@ -0,0 +1,340 @@ + + */ +abstract class PrefixAwareRepository extends EntityRepository +{ + /** + * @var array + * + * array [ + * 'nodeType' => [ + * 'type': 'inner', + * 'joins': [ + * 'n': 'obj.node', + * 't': 'node.nodeType' + * ] + * ] + * ] + */ + private array $prefixes = []; + + /** + * @return array + */ + public function getPrefixes(): array + { + return $this->prefixes; + } + + /** + * @return string + */ + public function getDefaultPrefix(): string + { + return EntityRepository::DEFAULT_ALIAS; + } + + /** + * @param string $prefix Ex. 'node' + * @param array $joins Ex. ['n': 'obj.node'] + * @param string $type Ex. 'inner'|'left', default 'left' + * @return $this + */ + public function addPrefix(string $prefix, array $joins, string $type = 'left'): self + { + if (!in_array($type, ['left', 'inner'])) { + throw new \InvalidArgumentException('Prefix type can only be "left" or "inner"'); + } + + if (!array_key_exists($prefix, $this->prefixes)) { + $this->prefixes[$prefix] = [ + 'joins' => $joins, + 'type' => $type + ]; + } + + return $this; + } + + + protected function prepareComparisons(array &$criteria, QueryBuilder $qb, string $alias): QueryBuilder + { + $simpleQB = new SimpleQueryBuilder($qb); + foreach ($criteria as $key => $value) { + /* + * Main QueryBuilder dispatch loop for + * custom properties criteria. + */ + $event = $this->dispatchQueryBuilderBuildEvent($qb, $key, $value); + + if (!$event->isPropagationStopped()) { + $baseKey = $simpleQB->getParameterKey($key); + $realKey = $this->getRealKey($qb, $key); + $qb->andWhere($simpleQB->buildExpressionWithoutBinding($value, $realKey['prefix'], $realKey['key'], $baseKey)); + } + } + + return $qb; + } + + /** + * @param QueryBuilder $qb + * @param string $key + * @return array + */ + protected function getRealKey(QueryBuilder $qb, string $key): array + { + $keyParts = explode('.', $key); + if (count($keyParts) > 1) { + if (array_key_exists($keyParts[0], $this->prefixes)) { + $lastPrefix = ''; + foreach ($this->prefixes[$keyParts[0]]['joins'] as $prefix => $field) { + if (!$this->hasJoinedPrefix($qb, $prefix)) { + switch ($this->prefixes[$keyParts[0]]['type']) { + case 'inner': + $qb->innerJoin($field, $prefix); + break; + case 'left': + $qb->leftJoin($field, $prefix); + break; + } + } + + $lastPrefix = $prefix; + } + return [ + 'prefix' => $lastPrefix . '.', + 'key' => $keyParts[1] + ]; + } + + throw new \InvalidArgumentException('"' . $keyParts[0] . '" prefix is not known for initiating joined queries.'); + } + + return [ + 'prefix' => $this->getDefaultPrefix() . '.', + 'key' => $key + ]; + } + + /** + * @param QueryBuilder $qb + * @param string $prefix + * @return bool + */ + protected function hasJoinedPrefix(QueryBuilder $qb, string $prefix): bool + { + return $this->joinExists($qb, $this->getDefaultPrefix(), $prefix); + } + + /** + * Count entities using a Criteria object or a simple filter array. + * + * @param array $criteria + * @param array|null $orderBy + * @param int|null $limit + * @param int|null $offset + * @return array|Paginator + * @psalm-return array|Paginator + */ + public function findBy( + array $criteria, + array $orderBy = null, + $limit = null, + $offset = null + ): array|Paginator { + $qb = $this->createQueryBuilder($this->getDefaultPrefix()); + $qb->select($this->getDefaultPrefix()); + $qb = $this->prepareComparisons($criteria, $qb, $this->getDefaultPrefix()); + + // Add ordering + if (null !== $orderBy) { + foreach ($orderBy as $key => $value) { + $realKey = $this->getRealKey($qb, $key); + $qb->addOrderBy($realKey['prefix'] . $realKey['key'], $value); + } + } + if (null !== $offset) { + $qb->setFirstResult($offset); + } + if (null !== $limit) { + $qb->setMaxResults($limit); + } + + $this->dispatchQueryBuilderEvent($qb, $this->getEntityName()); + $this->applyFilterByCriteria($criteria, $qb); + $query = $qb->getQuery(); + $this->dispatchQueryEvent($query); + + if ( + null !== $limit && + null !== $offset + ) { + /* + * We need to use Doctrine paginator + * if a limit is set because of the default inner join + */ + return new Paginator($query); + } else { + return $query->getResult(); + } + } + + /** + * Count entities using a Criteria object or a simple filter array. + * + * @param array $criteria + * @param array|null $orderBy + * + * @return Entity + * @psalm-return TEntityClass + * @throws \Doctrine\ORM\NonUniqueResultException + */ + public function findOneBy( + array $criteria, + array $orderBy = null + ) { + $qb = $this->createQueryBuilder($this->getDefaultPrefix()); + $qb->select($this->getDefaultPrefix()); + $qb = $this->prepareComparisons($criteria, $qb, $this->getDefaultPrefix()); + + // Add ordering + if (null !== $orderBy) { + foreach ($orderBy as $key => $value) { + $realKey = $this->getRealKey($qb, $key); + $qb->addOrderBy($realKey['prefix'] . $realKey['key'], $value); + } + } + + $qb->setMaxResults(1); + $this->dispatchQueryBuilderEvent($qb, $this->getEntityName()); + $this->applyFilterByCriteria($criteria, $qb); + $query = $qb->getQuery(); + $this->dispatchQueryEvent($query); + + return $query->getOneOrNullResult(); + } + + /** + * @param string $pattern Search pattern + * @param array $criteria Additional criteria + * @param array $orders + * @param integer $limit + * @param integer $offset + * @param string $alias + * + * @return array|Paginator + * @psalm-return array|Paginator + */ + public function searchBy( + string $pattern, + array $criteria = [], + array $orders = [], + $limit = null, + $offset = null, + string $alias = EntityRepository::DEFAULT_ALIAS + ): array|Paginator { + $qb = $this->createQueryBuilder($alias); + $qb->select($alias); + $qb = $this->createSearchBy($pattern, $qb, $criteria, $alias); + + // Add ordering + if (null !== $orders) { + foreach ($orders as $key => $value) { + $realKey = $this->getRealKey($qb, $key); + $qb->addOrderBy($realKey['prefix'] . $realKey['key'], $value); + } + } + + if (null !== $offset) { + $qb->setFirstResult($offset); + } + if (null !== $limit) { + $qb->setMaxResults($limit); + } + + $this->dispatchQueryBuilderEvent($qb, $this->getEntityName()); + $this->applyFilterByCriteria($criteria, $qb); + $query = $qb->getQuery(); + $this->dispatchQueryEvent($query); + + if ( + null !== $limit && + null !== $offset + ) { + /* + * We need to use Doctrine paginator + * if a limit is set because of the default inner join + */ + return new Paginator($query); + } else { + return $query->getResult(); + } + } + + public function countSearchBy(string $pattern, array $criteria = []): int + { + $qb = $this->createQueryBuilder($this->getDefaultPrefix()); + $qb->select($qb->expr()->countDistinct($this->getDefaultPrefix() . '.id')); + $qb = $this->createSearchBy($pattern, $qb, $criteria); + + $this->dispatchQueryBuilderEvent($qb, $this->getEntityName()); + $this->applyFilterByCriteria($criteria, $qb); + + return (int) $qb->getQuery()->getSingleScalarResult(); + } + + /** + * Create a LIKE comparison with entity texts columns. + * + * @param string $pattern + * @param QueryBuilder $qb + * @param string $alias + * @return QueryBuilder + */ + protected function classicLikeComparison( + string $pattern, + QueryBuilder $qb, + string $alias = "obj" + ): QueryBuilder { + /* + * Get fields needed for a search query + */ + $metadatas = $this->_em->getClassMetadata($this->getEntityName()); + $criteriaFields = []; + $cols = $metadatas->getColumnNames(); + foreach ($cols as $col) { + $field = $metadatas->getFieldName($col); + $type = $metadatas->getTypeOfField($field); + if ( + in_array($type, $this->searchableTypes) && + $field != 'folder' && + $field != 'childrenOrder' && + $field != 'childrenOrderDirection' + ) { + $criteriaFields[$field] = '%' . strip_tags((string) $pattern) . '%'; + } + } + + foreach ($criteriaFields as $key => $value) { + $realKey = $this->getRealKey($qb, $key); + $fullKey = sprintf('LOWER(%s)', $realKey['prefix'] . $realKey['key']); + $qb->orWhere($qb->expr()->like($fullKey, $qb->expr()->literal($value))); + } + return $qb; + } +} diff --git a/lib/RoadizCoreBundle/src/Repository/RealmNodeRepository.php b/lib/RoadizCoreBundle/src/Repository/RealmNodeRepository.php new file mode 100644 index 00000000..0e68b682 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Repository/RealmNodeRepository.php @@ -0,0 +1,35 @@ + + */ +final class RealmNodeRepository extends EntityRepository +{ + public function findByNodeIdsAndRealmId(array $nodeIds, int $realmId): array + { + $nodeIds = array_filter($nodeIds); + if (empty($nodeIds)) { + return []; + } + + $qb = $this->createQueryBuilder('rn'); + $qb->andWhere($qb->expr()->in('rn.node', ':nodeIds')) + ->andWhere($qb->expr()->eq('rn.realm', ':realmId')) + ->setParameter('nodeIds', $nodeIds) + ->setParameter('realmId', $realmId); + + return $qb->getQuery()->getResult(); + } +} diff --git a/lib/RoadizCoreBundle/src/Repository/RealmRepository.php b/lib/RoadizCoreBundle/src/Repository/RealmRepository.php new file mode 100644 index 00000000..21b4c507 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Repository/RealmRepository.php @@ -0,0 +1,44 @@ + + */ +final class RealmRepository extends EntityRepository +{ + public function findByNode(Node $node): array + { + $qb = $this->createQueryBuilder('r'); + $qb->innerJoin('r.realmNodes', 'rn') + ->andWhere($qb->expr()->in('rn.node', ':node')) + ->andWhere($qb->expr()->isNotNull('rn.realm')) + ->setParameter('node', $node); + + return $qb->getQuery()->getResult(); + } + + public function findByNodeAndBehaviour(Node $node, string $realmBehaviour): array + { + $qb = $this->createQueryBuilder('r'); + $qb->innerJoin('r.realmNodes', 'rn') + ->andWhere($qb->expr()->in('rn.node', ':node')) + ->andWhere($qb->expr()->eq('r.behaviour', ':behaviour')) + ->andWhere($qb->expr()->isNotNull('rn.realm')) + ->setParameter('node', $node) + ->setParameter('behaviour', $realmBehaviour); + + return $qb->getQuery()->getResult(); + } +} diff --git a/lib/RoadizCoreBundle/src/Repository/RedirectionRepository.php b/lib/RoadizCoreBundle/src/Repository/RedirectionRepository.php new file mode 100644 index 00000000..507b4989 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Repository/RedirectionRepository.php @@ -0,0 +1,25 @@ + + */ +final class RedirectionRepository extends EntityRepository +{ + public function __construct(ManagerRegistry $registry, EventDispatcherInterface $dispatcher) + { + parent::__construct($registry, Redirection::class, $dispatcher); + } +} diff --git a/lib/RoadizCoreBundle/src/Repository/RoleRepository.php b/lib/RoadizCoreBundle/src/Repository/RoleRepository.php new file mode 100644 index 00000000..7a0cb83d --- /dev/null +++ b/lib/RoadizCoreBundle/src/Repository/RoleRepository.php @@ -0,0 +1,110 @@ + + */ +final class RoleRepository extends EntityRepository +{ + public function __construct( + ManagerRegistry $registry, + EventDispatcherInterface $dispatcher + ) { + parent::__construct($registry, Role::class, $dispatcher); + } + + /** + * @param string $roleName + * @return int + * @throws NoResultException + * @throws NonUniqueResultException + */ + public function countByName(string $roleName): int + { + $roleName = Role::cleanName($roleName); + + $query = $this->createQueryBuilder('r'); + $query->select($query->expr()->countDistinct('r')) + ->andWhere($query->expr()->eq('r.name', ':name')) + ->setParameter('name', $roleName); + + return (int) $query->getQuery()->getSingleScalarResult(); + } + + /** + * @param string $roleName + * @return Role + * @throws NonUniqueResultException + * @throws ORMException + */ + public function findOneByName(string $roleName): Role + { + $roleName = Role::cleanName($roleName); + + $query = $this->createQueryBuilder('r'); + $query->andWhere($query->expr()->eq('r.name', ':name')) + ->setMaxResults(1) + ->setParameter('name', $roleName); + + $role = $query->getQuery()->getOneOrNullResult(); + if (null === $role) { + $role = new Role($roleName); + $this->_em->persist($role); + $this->_em->flush(); + } + + return $role; + } + + /** + * Get every Role names except for ROLE_SUPERADMIN. + * + * @return array + */ + public function getAllBasicRoleName(): array + { + $builder = $this->createQueryBuilder('r'); + $builder->select('r.name') + ->andWhere($builder->expr()->neq('r.name', ':name')) + ->setParameter('name', Role::ROLE_SUPERADMIN); + + $query = $builder->getQuery(); + $query->enableResultCache(3600, 'RZRoleAllBasic'); + + return array_map('current', $query->getScalarResult()); + } + + /** + * Get every Role names + * + * @return array + */ + public function getAllRoleName(): array + { + $builder = $this->createQueryBuilder('r'); + $builder->select('r.name'); + + $query = $builder->getQuery(); + $query->enableResultCache(3600, 'RZRoleAll'); + + return array_map('current', $query->getScalarResult()); + } +} diff --git a/lib/RoadizCoreBundle/src/Repository/SettingGroupRepository.php b/lib/RoadizCoreBundle/src/Repository/SettingGroupRepository.php new file mode 100644 index 00000000..35be698f --- /dev/null +++ b/lib/RoadizCoreBundle/src/Repository/SettingGroupRepository.php @@ -0,0 +1,53 @@ + + */ +final class SettingGroupRepository extends EntityRepository +{ + public function __construct( + ManagerRegistry $registry, + EventDispatcherInterface $dispatcher + ) { + parent::__construct($registry, SettingGroup::class, $dispatcher); + } + + /** + * @param string $name + * + * @return bool + * @throws \Doctrine\ORM\NoResultException + * @throws \Doctrine\ORM\NonUniqueResultException + */ + public function exists(string $name): bool + { + $query = $this->_em->createQuery(' + SELECT COUNT(s.id) FROM RZ\Roadiz\CoreBundle\Entity\SettingGroup s + WHERE s.name = :name') + ->setParameter('name', $name); + + return (bool) $query->getSingleScalarResult(); + } + + /** + * @return array + */ + public function findAllNames(): array + { + $query = $this->_em->createQuery('SELECT s.name FROM RZ\Roadiz\CoreBundle\Entity\SettingGroup s'); + return array_map('current', $query->getScalarResult()); + } +} diff --git a/lib/RoadizCoreBundle/src/Repository/SettingRepository.php b/lib/RoadizCoreBundle/src/Repository/SettingRepository.php new file mode 100644 index 00000000..19483196 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Repository/SettingRepository.php @@ -0,0 +1,84 @@ + + */ +final class SettingRepository extends EntityRepository +{ + public function __construct( + ManagerRegistry $registry, + EventDispatcherInterface $dispatcher + ) { + parent::__construct($registry, Setting::class, $dispatcher); + } + + /** + * @param string $name + * + * @return mixed + * @throws NoResultException + * @throws NonUniqueResultException + */ + public function getValue(string $name): mixed + { + $builder = $this->createQueryBuilder('s'); + $builder->select('s.value') + ->andWhere($builder->expr()->eq('s.name', ':name')) + ->setParameter(':name', $name); + + $query = $builder->getQuery(); + $query->enableResultCache(3600, 'RZSettingValue_' . $name); + + return $query->getSingleScalarResult(); + } + + /** + * @param string $name + * + * @return bool + * @throws NoResultException + * @throws NonUniqueResultException + */ + public function exists(string $name): bool + { + $builder = $this->createQueryBuilder('s'); + $builder->select($builder->expr()->count('s.value')) + ->andWhere($builder->expr()->eq('s.name', ':name')) + ->setParameter(':name', $name); + + $query = $builder->getQuery(); + $query->enableResultCache(3600, 'RZSettingExists_' . $name); + + return (bool) $query->getSingleScalarResult(); + } + + /** + * Get every Setting names + * + * @return array + */ + public function findAllNames(): array + { + $builder = $this->createQueryBuilder('s'); + $builder->select('s.name'); + $query = $builder->getQuery(); + $query->enableResultCache(3600, 'RZSettingAll'); + + return array_map('current', $query->getScalarResult()); + } +} diff --git a/lib/RoadizCoreBundle/src/Repository/StatusAwareRepository.php b/lib/RoadizCoreBundle/src/Repository/StatusAwareRepository.php new file mode 100644 index 00000000..87fed3d5 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Repository/StatusAwareRepository.php @@ -0,0 +1,113 @@ + + */ +abstract class StatusAwareRepository extends EntityRepository +{ + private bool $displayNotPublishedNodes; + private bool $displayAllNodesStatuses; + protected Security $security; + protected PreviewResolverInterface $previewResolver; + + /** + * @param ManagerRegistry $registry + * @param string $entityClass + * @param PreviewResolverInterface $previewResolver + * @param EventDispatcherInterface $dispatcher + * @param Security $security + */ + public function __construct( + ManagerRegistry $registry, + string $entityClass, + PreviewResolverInterface $previewResolver, + EventDispatcherInterface $dispatcher, + Security $security + ) { + parent::__construct($registry, $entityClass, $dispatcher); + + $this->displayNotPublishedNodes = false; + $this->displayAllNodesStatuses = false; + $this->security = $security; + $this->previewResolver = $previewResolver; + } + + + /** + * @return bool + */ + public function isDisplayingNotPublishedNodes(): bool + { + return $this->displayNotPublishedNodes; + } + + /** + * @param bool $displayNotPublishedNodes + * @return static + */ + public function setDisplayingNotPublishedNodes(bool $displayNotPublishedNodes) + { + $this->displayNotPublishedNodes = $displayNotPublishedNodes; + return $this; + } + + /** + * @return bool + */ + public function isDisplayingAllNodesStatuses(): bool + { + return $this->displayAllNodesStatuses; + } + + /** + * Switch repository to disable any security on Node status. To use ONLY in order to + * view deleted and archived nodes. + * + * @param bool $displayAllNodesStatuses + * + * @return static + */ + public function setDisplayingAllNodesStatuses(bool $displayAllNodesStatuses) + { + $this->displayAllNodesStatuses = $displayAllNodesStatuses; + return $this; + } + + /** + * @param QueryBuilder $qb + * @param string $prefix + * @return QueryBuilder + */ + public function alterQueryBuilderWithAuthorizationChecker( + QueryBuilder $qb, + string $prefix = EntityRepository::NODE_ALIAS + ): QueryBuilder { + if (true === $this->isDisplayingAllNodesStatuses()) { + // do not filter on status + return $qb; + } + /* + * Check if user can see not-published node based on its Token + * and context. + */ + if (true === $this->isDisplayingNotPublishedNodes() || $this->previewResolver->isPreview()) { + $qb->andWhere($qb->expr()->lte($prefix . '.status', Node::PUBLISHED)); + } else { + $qb->andWhere($qb->expr()->eq($prefix . '.status', Node::PUBLISHED)); + } + + return $qb; + } +} diff --git a/lib/RoadizCoreBundle/src/Repository/TagRepository.php b/lib/RoadizCoreBundle/src/Repository/TagRepository.php new file mode 100644 index 00000000..214841c2 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Repository/TagRepository.php @@ -0,0 +1,770 @@ + + */ +final class TagRepository extends EntityRepository +{ + public function __construct( + ManagerRegistry $registry, + EventDispatcherInterface $dispatcher + ) { + parent::__construct($registry, Tag::class, $dispatcher); + } + + /** + * Add a node filtering to queryBuilder. + * + * @param array $criteria + * @param QueryBuilder $qb + */ + protected function filterByNodes(array &$criteria, QueryBuilder $qb): void + { + if (key_exists('nodes', $criteria)) { + if (is_array($criteria['nodes']) || $criteria['nodes'] instanceof Collection) { + $qb->innerJoin( + 'tg.nodesTags', + 'ntg', + 'WITH', + 'ntg.node IN (:nodes)' + ); + } else { + $qb->innerJoin( + 'tg.nodesTags', + 'ntg', + 'WITH', + 'ntg.node = :nodes' + ); + } + } + } + + /** + * Bind node parameter to final query + * + * @param array $criteria + * @param QueryBuilder $qb + */ + protected function applyFilterByNodes(array &$criteria, QueryBuilder $qb): void + { + if (key_exists('nodes', $criteria)) { + if ($criteria['nodes'] instanceof Node) { + $qb->setParameter('nodes', $criteria['nodes']->getId()); + } elseif ( + is_array($criteria['nodes']) || + $criteria['nodes'] instanceof Collection + ) { + $qb->setParameter('nodes', $criteria['nodes']); + } elseif (is_integer($criteria['nodes'])) { + $qb->setParameter('nodes', (int) $criteria['nodes']); + } + unset($criteria['nodes']); + } + } + + /** + * Bind parameters to generated query. + * + * @param array $criteria + * @param QueryBuilder $qb + */ + protected function applyFilterByCriteria(array &$criteria, QueryBuilder $qb): void + { + /* + * Reimplementing findBy features… + */ + $simpleQB = new SimpleQueryBuilder($qb); + foreach ($criteria as $key => $value) { + $event = $this->dispatchQueryBuilderApplyEvent($qb, $key, $value); + if (!$event->isPropagationStopped()) { + $simpleQB->bindValue($key, $value); + } + } + } + + /** + * Create filters according to any translation criteria OR argument. + * + * @param array $criteria + * @param QueryBuilder $qb + * @param TranslationInterface|null $translation + */ + protected function filterByTranslation( + array &$criteria, + QueryBuilder $qb, + TranslationInterface $translation = null + ): void { + if ( + isset($criteria['translation']) || + isset($criteria['translation.locale']) || + isset($criteria['translation.id']) + ) { + $qb->leftJoin('tg.translatedTags', 'tt'); + $qb->leftJoin('tt.translation', static::TRANSLATION_ALIAS); + } else { + if (null !== $translation) { + /* + * With a given translation + */ + $qb->leftJoin( + 'tg.translatedTags', + 'tt', + 'WITH', + 'tt.translation = :translation' + ); + } else { + /* + * With a null translation, just take the default one. + */ + $qb->leftJoin('tg.translatedTags', 'tt'); + $qb->leftJoin( + 'tt.translation', + static::TRANSLATION_ALIAS, + 'WITH', + 't.defaultTranslation = true' + ); + } + } + } + + /** + * Bind translation parameter to final query + * + * @param QueryBuilder $qb + * @param TranslationInterface|null $translation + */ + protected function applyTranslationByTag( + QueryBuilder $qb, + TranslationInterface $translation = null + ): void { + if (null !== $translation) { + $qb->setParameter('translation', $translation); + } + } + + /** + * This method allows to pre-filter Nodes with a given translation. + * + * @param array $criteria + * @param array|null $orderBy + * @param int|null $limit + * @param int|null $offset + * @param TranslationInterface|null $translation + * + * @return QueryBuilder + */ + protected function getContextualQueryWithTranslation( + array &$criteria, + array $orderBy = null, + ?int $limit = null, + ?int $offset = null, + TranslationInterface $translation = null + ): QueryBuilder { + $qb = $this->createQueryBuilder(EntityRepository::TAG_ALIAS); + $qb->addSelect('tt'); + $this->filterByNodes($criteria, $qb); + $this->filterByTranslation($criteria, $qb, $translation); + $this->prepareComparisons($criteria, $qb, EntityRepository::TAG_ALIAS); + + // Add ordering + if (null !== $orderBy) { + foreach ($orderBy as $key => $value) { + $qb->addOrderBy(EntityRepository::TAG_ALIAS . '.' . $key, $value); + } + } + + if (null !== $offset) { + $qb->setFirstResult($offset); + } + if (null !== $limit) { + $qb->setMaxResults($limit); + } + + return $qb; + } + /** + * This method allows to pre-filter Nodes with a given translation. + * + * @param array $criteria + * @param TranslationInterface|null $translation + * + * @return QueryBuilder + */ + protected function getCountContextualQueryWithTranslation( + array &$criteria, + TranslationInterface $translation = null + ): QueryBuilder { + $qb = $this->createQueryBuilder(EntityRepository::TAG_ALIAS); + $this->filterByNodes($criteria, $qb); + $this->filterByTranslation($criteria, $qb, $translation); + $this->prepareComparisons($criteria, $qb, EntityRepository::TAG_ALIAS); + + return $qb->select($qb->expr()->countDistinct(EntityRepository::TAG_ALIAS)); + } + + /** + * Just like the findBy method but with relational criteria. + * + * @param array $criteria + * @param array|string[]|null $orderBy + * @param integer|null $limit + * @param integer|null $offset + * @param TranslationInterface|null $translation + * + * @return array|Paginator + */ + public function findBy( + array $criteria, + array $orderBy = null, + $limit = null, + $offset = null, + TranslationInterface $translation = null + ): array|Paginator { + $qb = $this->getContextualQueryWithTranslation( + $criteria, + $orderBy, + $limit, + $offset, + $translation + ); + + $this->dispatchQueryBuilderEvent($qb, $this->getEntityName()); + $this->applyFilterByNodes($criteria, $qb); + $this->applyFilterByCriteria($criteria, $qb); + $this->applyTranslationByTag($qb, $translation); + $query = $qb->getQuery(); + $this->dispatchQueryEvent($query); + + if ( + null !== $limit && + null !== $offset + ) { + /* + * We need to use Doctrine paginator + * if a limit is set because of the default inner join + */ + return new Paginator($query); + } else { + return $query->getResult(); + } + } + + /** + * Just like the findOneBy method but with relational criteria. + * + * @param array $criteria + * @param array|null $orderBy + * @param TranslationInterface|null $translation + * + * @return Tag|null + * @throws NonUniqueResultException + */ + public function findOneBy( + array $criteria, + array $orderBy = null, + TranslationInterface $translation = null + ) { + $qb = $this->getContextualQueryWithTranslation( + $criteria, + $orderBy, + 1, + 0, + $translation + ); + + $this->dispatchQueryBuilderEvent($qb, $this->getEntityName()); + $this->applyFilterByNodes($criteria, $qb); + $this->applyFilterByCriteria($criteria, $qb); + $this->applyTranslationByTag($qb, $translation); + $query = $qb->getQuery(); + $this->dispatchQueryEvent($query); + + return $query->getOneOrNullResult(); + } + + /** + * Just like the countBy method but with relational criteria. + * + * @param array $criteria + * @param TranslationInterface|null $translation + * @return int + * @throws NonUniqueResultException + * @throws NoResultException + */ + public function countBy( + mixed $criteria, + TranslationInterface $translation = null + ): int { + $query = $this->getCountContextualQueryWithTranslation( + $criteria, + $translation + ); + + $this->dispatchQueryBuilderEvent($query, $this->getEntityName()); + $this->applyFilterByNodes($criteria, $query); + $this->applyFilterByCriteria($criteria, $query); + $this->applyTranslationByTag($query, $translation); + + return (int) $query->getQuery()->getSingleScalarResult(); + } + + /** + * @param int $tagId + * @param TranslationInterface $translation + * + * @return Tag|null + * @throws NonUniqueResultException + */ + public function findWithTranslation($tagId, TranslationInterface $translation) + { + $qb = $this->createQueryBuilder('t'); + $qb->select('t, tt') + ->leftJoin('t.translatedTags', 'tt') + ->andWhere($qb->expr()->eq('tt.translation', ':translation')) + ->andWhere($qb->expr()->eq('t.id', ':id')) + ->setParameter(':translation', $translation) + ->setParameter(':id', $tagId) + ->setMaxResults(1) + ; + + return $qb->getQuery()->getOneOrNullResult(); + } + + /** + * @param TranslationInterface $translation + * @return Tag[] + */ + public function findAllWithTranslation(TranslationInterface $translation) + { + $qb = $this->createQueryBuilder('t'); + $qb->select('t, tt') + ->leftJoin('t.translatedTags', 'tt') + ->andWhere($qb->expr()->eq('tt.translation', ':translation')) + ->setParameter(':translation', $translation) + ; + + return $qb->getQuery()->getResult(); + } + + /** + * @param int $tagId + * + * @return Tag|null + */ + public function findWithDefaultTranslation($tagId) + { + $qb = $this->createQueryBuilder('t'); + $qb->select('t, tt') + ->leftJoin('t.translatedTags', 'tt') + ->leftJoin('tt.translation', 'tr') + ->andWhere($qb->expr()->eq('tr.defaultTranslation', ':defaultTranslation')) + ->andWhere($qb->expr()->eq('t.id', ':id')) + ->setParameter(':defaultTranslation', true) + ->setParameter(':id', $tagId) + ->setMaxResults(1) + ; + + return $qb->getQuery()->getOneOrNullResult(); + } + + /** + * @return Tag[] + */ + public function findAllWithDefaultTranslation() + { + $qb = $this->createQueryBuilder('t'); + $qb->select('t, tt') + ->leftJoin('t.translatedTags', 'tt') + ->leftJoin('tt.translation', 'tr') + ->addOrderBy('t.tagName', 'ASC') + ->andWhere($qb->expr()->eq('tr.defaultTranslation', ':defaultTranslation')) + ->setParameter(':defaultTranslation', true) + ; + + return $qb->getQuery()->getResult(); + } + + /** + * @return Tag[] + */ + public function findAllColored() + { + $qb = $this->createQueryBuilder('t'); + $qb + ->andWhere($qb->expr()->isNotNull('t.color')) + ->andWhere($qb->expr()->notIn('t.color', ':colored')) + ->addOrderBy('t.position', 'DESC') + ->setParameter(':colored', [ + '#000000', + '#000', + '#fff', + '#ffffff', + ]) + ; + return $qb->getQuery()->getResult(); + } + + /** + * @param Node $parentNode + * @param TranslationInterface|null $translation + * + * @return Tag[] + */ + public function findAllLinkedToNodeChildren(Node $parentNode, ?TranslationInterface $translation = null) + { + $qb = $this->createQueryBuilder('t'); + $qb->select('t') + ->addSelect('tt') + ->addSelect('tr') + ->innerJoin('t.nodesTags', 'ntg') + ->innerJoin('ntg.node', 'n') + ->innerJoin('n.parent', 'pn') + ->leftJoin('t.translatedTags', 'tt') + ->leftJoin('tt.translation', 'tr') + ->andWhere($qb->expr()->eq('pn', ':parentNode')) + ->setParameter('parentNode', $parentNode) + ->addOrderBy('t.tagName', 'ASC') + ; + if (null !== $translation) { + $qb->innerJoin('n.nodeSources', 'ns') + ->andWhere($qb->expr()->eq('tt.translation', ':translation')) + ->andWhere($qb->expr()->eq('ns.translation', ':translation')) + ->setParameter('translation', $translation); + } + return $qb->getQuery() + ->setHint(\Doctrine\ORM\Query::HINT_FORCE_PARTIAL_LOAD, true) + ->getResult() + ; + } + + /** + * @param TranslationInterface $translation + * @param Tag $parent + * + * @return Tag[] + */ + public function findByParentWithTranslation(TranslationInterface $translation, Tag $parent = null) + { + $qb = $this->createQueryBuilder('t'); + $qb->select('t, tt') + ->leftJoin('t.translatedTags', 'tt') + ->andWhere($qb->expr()->eq('tt.translation', ':translation')) + ->addOrderBy('t.position', 'ASC') + ->setParameter(':translation', $translation) + ; + + if (null !== $parent) { + $qb->andWhere($qb->expr()->eq('t.parent', ':parent')) + ->setParameter(':parent', $parent); + } else { + $qb->andWhere($qb->expr()->isNull('t.parent')); + } + + return $qb->getQuery()->getResult(); + } + + /** + * @param Tag|null $parent + * + * @return Tag[] + */ + public function findByParentWithDefaultTranslation(Tag $parent = null) + { + $qb = $this->createQueryBuilder('t'); + $qb->select('t, tt') + ->leftJoin('t.translatedTags', 'tt') + ->leftJoin('tt.translation', 'tr') + ->addOrderBy('t.position', 'ASC') + ->andWhere($qb->expr()->eq('tr.defaultTranslation', ':defaultTranslation')) + ->setParameter(':defaultTranslation', true) + ; + + if (null !== $parent) { + $qb->andWhere($qb->expr()->eq('t.parent', ':parent')) + ->setParameter(':parent', $parent); + } else { + $qb->andWhere($qb->expr()->isNull('t.parent')); + } + + return $qb->getQuery()->getResult(); + } + + /** + * Returns only Tags that have children. + * + * @param Tag|null $parent + * @return Tag[] + */ + public function findByParentWithChildrenAndDefaultTranslation(Tag $parent = null) + { + $qb = $this->createQueryBuilder('t'); + $qb->select('t, tt') + ->leftJoin('t.translatedTags', 'tt') + ->leftJoin('tt.translation', 'tr') + ->innerJoin('t.children', 'ct') + ->andWhere($qb->expr()->eq('tr.defaultTranslation', ':defaultTranslation')) + ->andWhere($qb->expr()->isNotNull('ct.id')) + ->addOrderBy('t.position', 'ASC') + ->setParameter(':defaultTranslation', true) + ; + + if (null !== $parent) { + $qb->andWhere($qb->expr()->eq('t.parent', ':parent')) + ->setParameter(':parent', $parent); + } else { + $qb->andWhere($qb->expr()->isNull('t.parent')); + } + + return $qb->getQuery()->getResult(); + } + + /** + * Create a Criteria object from a search pattern and additional fields. + * + * @param string $pattern Search pattern + * @param QueryBuilder $qb QueryBuilder to pass + * @param array $criteria Additional criteria + * @param string $alias SQL query table alias + * + * @return QueryBuilder + */ + protected function createSearchBy( + string $pattern, + QueryBuilder $qb, + array &$criteria = [], + string $alias = EntityRepository::DEFAULT_ALIAS + ): QueryBuilder { + $this->classicLikeComparison($pattern, $qb, $alias); + + /* + * Search in translations + */ + $qb->leftJoin($alias . '.translatedTags', 'tt'); + $criteriaFields = []; + $metadatas = $this->_em->getClassMetadata(TagTranslation::class); + $cols = $metadatas->getColumnNames(); + foreach ($cols as $col) { + $field = $metadatas->getFieldName($col); + $type = $metadatas->getTypeOfField($field); + if (in_array($type, $this->searchableTypes)) { + $criteriaFields[$field] = '%' . strip_tags((string) $pattern) . '%'; + } + } + foreach ($criteriaFields as $key => $value) { + $fullKey = sprintf('LOWER(%s)', 'tt.' . $key); + $qb->orWhere($qb->expr()->like($fullKey, $qb->expr()->literal($value))); + } + + $qb = $this->prepareComparisons($criteria, $qb, $alias); + + return $qb; + } + + /** + * @param array $criteria + * @param QueryBuilder $qb + * @param string $alias + * @return QueryBuilder + */ + protected function prepareComparisons(array &$criteria, QueryBuilder $qb, $alias) + { + $simpleQB = new SimpleQueryBuilder($qb); + foreach ($criteria as $key => $value) { + /* + * Main QueryBuilder dispatch loop for + * custom properties criteria. + */ + $event = $this->dispatchQueryBuilderBuildEvent($qb, $key, $value); + + if (!$event->isPropagationStopped()) { + /* + * Search in node fields + */ + if ($key == 'nodes') { + continue; + } + + /* + * compute prefix for + * filtering node, and sources relation fields + */ + $prefix = $alias; + + // Dots are forbidden in field definitions + $baseKey = $simpleQB->getParameterKey($key); + + if (false !== strpos($key, 'translation.')) { + /* + * Search in translation fields + */ + $prefix = static::TRANSLATION_ALIAS . '.'; + $key = str_replace('translation.', '', $key); + } elseif (false !== strpos($key, 'nodes.')) { + /* + * Search in node fields + */ + $prefix = static::NODE_ALIAS . '.'; + $key = str_replace('nodes.', '', $key); + } elseif (false !== strpos($key, 'translatedTag.')) { + /* + * Search in translatedTags fields + */ + $prefix = 'tt.'; + $key = str_replace('translatedTag.', '', $key); + } elseif ($key === 'translation') { + /* + * Search in translation fields + */ + $prefix = 'tt.'; + } + $qb->andWhere($simpleQB->buildExpressionWithoutBinding($value, $prefix, $key, $baseKey)); + } + } + + return $qb; + } + + /** + * Find a tag according to the given path or create it. + * + * @param string $tagPath + * @param TranslationInterface|null $translation + * + * @return Tag|null + * @throws \Doctrine\ORM\ORMException + * @throws \Doctrine\ORM\OptimisticLockException + */ + public function findOrCreateByPath(string $tagPath, ?TranslationInterface $translation = null) + { + $tagPath = trim($tagPath); + $tags = explode('/', $tagPath); + $tags = array_filter($tags); + + if (count($tags) === 0) { + return null; + } + + $tagName = $tags[count($tags) - 1]; + $tag = $this->findOneByTagName(StringHandler::slugify($tagName)); + + if (null === $tag) { + /** @var TagTranslation|null $ttag */ + $ttag = $this->_em->getRepository(TagTranslation::class)->findOneByName($tagName); + if (null !== $ttag) { + $tag = $ttag->getTag(); + } + } + + if (null === $tag) { + /* + * Creation of a new tag + * before linking it to the node + */ + $parentName = null; + $parentTag = null; + + if (count($tags) > 1) { + $parentName = $tags[count($tags) - 2]; + $parentTag = $this->findOneByTagName(StringHandler::slugify($parentName)); + + if (null === $parentTag) { + $ttagParent = $this->_em->getRepository(TagTranslation::class)->findOneByName($parentName); + if (null !== $ttagParent) { + $parentTag = $ttagParent->getTag(); + } + } + } + if (null === $translation) { + $translation = $this->_em->getRepository(Translation::class)->findDefault(); + } + + $tag = new Tag(); + $tag->setTagName($tagName); + $translatedTag = new TagTranslation($tag, $translation); + $translatedTag->setName($tagName); + $tag->getTranslatedTags()->add($translatedTag); + + if (null !== $parentTag) { + $tag->setParent($parentTag); + } + + $this->_em->persist($translatedTag); + $this->_em->persist($tag); + $this->_em->flush(); + } + + return $tag; + } + + /** + * Find a tag according to the given path. + * + * @param string $tagPath + * + * @return Tag|null + */ + public function findByPath(string $tagPath) + { + $tagPath = trim($tagPath); + $tags = explode('/', $tagPath); + $tags = array_filter($tags); + $lastToken = count($tags) - 1; + $tagName = count($tags) > 0 ? $tags[$lastToken] : $tagPath; + + $tag = $this->findOneByTagName(StringHandler::slugify($tagName)); + + if (null === $tag) { + $ttag = $this->_em->getRepository(TagTranslation::class)->findOneByName($tagName); + if (null !== $ttag) { + $tag = $ttag->getTag(); + } + } + + return $tag; + } + + /** + * Get latest position in parent. + * + * Parent can be null for tag root + * + * @param Tag|null $parent + * @return int + */ + public function findLatestPositionInParent(Tag $parent = null) + { + $qb = $this->createQueryBuilder('t'); + $qb->select($qb->expr()->max('t.position')); + + if (null !== $parent) { + $qb->andWhere($qb->expr()->eq('t.parent', ':parent')) + ->setParameter(':parent', $parent); + } else { + $qb->andWhere($qb->expr()->isNull('t.parent')); + } + + return (int) $qb->getQuery()->getSingleScalarResult(); + } +} diff --git a/lib/RoadizCoreBundle/src/Repository/TagTranslationDocumentsRepository.php b/lib/RoadizCoreBundle/src/Repository/TagTranslationDocumentsRepository.php new file mode 100644 index 00000000..e693ca4d --- /dev/null +++ b/lib/RoadizCoreBundle/src/Repository/TagTranslationDocumentsRepository.php @@ -0,0 +1,44 @@ + + */ +final class TagTranslationDocumentsRepository extends EntityRepository +{ + public function __construct( + ManagerRegistry $registry, + EventDispatcherInterface $dispatcher + ) { + parent::__construct($registry, TagTranslationDocuments::class, $dispatcher); + } + /** + * @param TagTranslation $tagTranslation + * + * @return integer + * @throws \Doctrine\ORM\NoResultException + * @throws \Doctrine\ORM\NonUniqueResultException + */ + public function getLatestPosition($tagTranslation) + { + $query = $this->_em->createQuery('SELECT MAX(ttd.position) +FROM RZ\Roadiz\CoreBundle\Entity\TagTranslationDocuments ttd +WHERE ttd.tagTranslation = :tagTranslation') + ->setParameter('tagTranslation', $tagTranslation); + + return (int) $query->getSingleScalarResult(); + } +} diff --git a/lib/RoadizCoreBundle/src/Repository/TagTranslationRepository.php b/lib/RoadizCoreBundle/src/Repository/TagTranslationRepository.php new file mode 100644 index 00000000..8c74f408 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Repository/TagTranslationRepository.php @@ -0,0 +1,25 @@ + + */ +final class TagTranslationRepository extends EntityRepository +{ + public function __construct(ManagerRegistry $registry, EventDispatcherInterface $dispatcher) + { + parent::__construct($registry, TagTranslation::class, $dispatcher); + } +} diff --git a/lib/RoadizCoreBundle/src/Repository/TranslationRepository.php b/lib/RoadizCoreBundle/src/Repository/TranslationRepository.php new file mode 100644 index 00000000..1e57603e --- /dev/null +++ b/lib/RoadizCoreBundle/src/Repository/TranslationRepository.php @@ -0,0 +1,455 @@ + + */ +final class TranslationRepository extends EntityRepository +{ + public function __construct( + ManagerRegistry $registry, + EventDispatcherInterface $dispatcher + ) { + parent::__construct($registry, Translation::class, $dispatcher); + } + + /** + * Get single default translation. + * + * @return TranslationInterface|null + * @throws NonUniqueResultException + */ + public function findDefault(): ?TranslationInterface + { + $qb = $this->createQueryBuilder('t'); + $qb->andWhere($qb->expr()->eq('t.available', ':available')) + ->andWhere($qb->expr()->eq('t.defaultTranslation', ':defaultTranslation')) + ->setParameter(':available', true) + ->setParameter(':defaultTranslation', true) + ->setMaxResults(1) + ->setCacheable(true); + + $query = $qb->getQuery(); + $query->enableResultCache(1800, 'RZTranslationDefault'); + + return $query->getOneOrNullResult(); + } + + /** + * Get all available translations. + * + * @return TranslationInterface[] + */ + public function findAllAvailable(): array + { + $qb = $this->createQueryBuilder('t'); + $qb->andWhere($qb->expr()->eq('t.available', ':available')) + // Default translation should be first + ->addOrderBy('t.defaultTranslation', 'DESC') + ->setParameter(':available', true) + ->setCacheable(true); + + $query = $qb->getQuery(); + $query->enableResultCache(1800, 'RZTranslationAllAvailable'); + + return $query->getResult(); + } + + /** + * @param string $locale + * + * @return bool + * @throws NonUniqueResultException + * @throws NoResultException + */ + public function exists(string $locale): bool + { + $qb = $this->createQueryBuilder('t'); + $qb->select($qb->expr()->countDistinct('t.locale')) + ->andWhere($qb->expr()->eq('t.locale', ':locale')) + ->setParameter(':locale', $locale) + ->setCacheable(true); + + $query = $qb->getQuery(); + $query->enableResultCache(120, 'RZTranslationExists-' . $locale); + + return (bool) $query->getSingleScalarResult(); + } + + /** + * Get all available locales. + * + * @return array + */ + public function getAvailableLocales(): array + { + $qb = $this->createQueryBuilder('t'); + $qb->select('t.locale') + ->andWhere($qb->expr()->eq('t.available', ':available')) + // Default translation should be first + ->addOrderBy('t.defaultTranslation', 'DESC') + ->setParameter(':available', true) + ->setCacheable(true); + + $query = $qb->getQuery(); + $query->enableResultCache(120, 'RZTranslationGetAvailableLocales'); + + return array_map('current', $query->getScalarResult()); + } + + /** + * Get all locales. + * + * @return array + */ + public function getAllLocales(): array + { + $qb = $this->createQueryBuilder('t'); + $qb->select('t.locale') + // Default translation should be first + ->addOrderBy('t.defaultTranslation', 'DESC') + ->setCacheable(true); + + $query = $qb->getQuery(); + $query->enableResultCache(120, 'RZTranslationGetAllLocales'); + + return array_map('current', $query->getScalarResult()); + } + + /** + * Get all available locales. + * + * @return array + */ + public function getAvailableOverrideLocales(): array + { + $qb = $this->createQueryBuilder('t'); + $qb->select('t.overrideLocale') + ->andWhere($qb->expr()->isNotNull('t.overrideLocale')) + ->andWhere($qb->expr()->neq('t.overrideLocale', ':overrideLocale')) + ->andWhere($qb->expr()->eq('t.available', ':available')) + // Default translation should be first + ->addOrderBy('t.defaultTranslation', 'DESC') + ->setParameter(':available', true) + ->setParameter(':overrideLocale', '') + ->setCacheable(true); + + $query = $qb->getQuery(); + $query->enableResultCache(120, 'RZTranslationGetAvailableOverrideLocales'); + + return array_map('current', $query->getScalarResult()); + } + + /** + * Get all available locales. + * + * @return array + */ + public function getAllOverrideLocales(): array + { + $qb = $this->createQueryBuilder('t'); + $qb->select('t.overrideLocale') + ->andWhere($qb->expr()->isNotNull('t.overrideLocale')) + ->andWhere($qb->expr()->neq('t.overrideLocale', ':overrideLocale')) + // Default translation should be first + ->addOrderBy('t.defaultTranslation', 'DESC') + ->setParameter(':overrideLocale', '') + ->setCacheable(true); + + $query = $qb->getQuery(); + $query->enableResultCache(120, 'RZTranslationGetAllOverrideLocales'); + + return array_map('current', $query->getScalarResult()); + } + + /** + * Get all available translations by locale. + * + * @param string $locale + * + * @return TranslationInterface[] + */ + public function findByLocaleAndAvailable(string $locale): array + { + $qb = $this->createQueryBuilder(self::TRANSLATION_ALIAS); + $qb->andWhere($qb->expr()->eq(self::TRANSLATION_ALIAS . '.available', ':available')) + ->andWhere($qb->expr()->eq(self::TRANSLATION_ALIAS . '.locale', ':locale')) + ->setParameter('available', true) + ->setParameter('locale', $locale) + ->setCacheable(true); + + $query = $qb->getQuery(); + $query->enableResultCache( + 120, + 'RZTranslationAllByLocaleAndAvailable-' . $locale + ); + + return $query->getResult(); + } + + /** + * Get all available translations by overrideLocale. + * + * @param string $overrideLocale + * @return TranslationInterface[] + */ + public function findByOverrideLocaleAndAvailable(string $overrideLocale): array + { + $qb = $this->createQueryBuilder(self::TRANSLATION_ALIAS); + $qb->andWhere($qb->expr()->eq(self::TRANSLATION_ALIAS . '.available', ':available')) + ->andWhere($qb->expr()->eq(self::TRANSLATION_ALIAS . '.overrideLocale', ':overrideLocale')) + ->setParameter('available', true) + ->setParameter('overrideLocale', $overrideLocale) + ->setCacheable(true); + + $query = $qb->getQuery(); + $query->enableResultCache( + 120, + 'RZTranslationAllByOverrideAndAvailable-' . $overrideLocale + ); + + return $query->getResult(); + } + + /** + * Get one translation by locale or override locale. + * + * @param string $locale + * @param string $alias + * + * @return TranslationInterface|null + * @throws NonUniqueResultException + */ + public function findOneByLocaleOrOverrideLocale( + string $locale, + string $alias = TranslationRepository::TRANSLATION_ALIAS + ): ?TranslationInterface { + $qb = $this->createQueryBuilder($alias); + $qb->andWhere($qb->expr()->orX( + $qb->expr()->eq($alias . '.locale', ':locale'), + $qb->expr()->eq($alias . '.overrideLocale', ':locale') + )) + ->setParameter('locale', $locale) + ->setMaxResults(1) + ->setCacheable(true); + + $query = $qb->getQuery(); + $query->enableResultCache(120, 'findOneByLocaleOrOverrideLocale_' . $locale); + + return $query->getOneOrNullResult(); + } + + /** + * Get one available translation by locale or override locale. + * + * @param string $locale + * + * @return TranslationInterface|null + * @throws NonUniqueResultException + */ + public function findOneAvailableByLocaleOrOverrideLocale(string $locale): ?TranslationInterface + { + $qb = $this->createQueryBuilder(self::TRANSLATION_ALIAS); + $qb->andWhere($qb->expr()->orX( + $qb->expr()->eq(self::TRANSLATION_ALIAS . '.locale', ':locale'), + $qb->expr()->eq(self::TRANSLATION_ALIAS . '.overrideLocale', ':locale') + )) + ->andWhere($qb->expr()->eq(self::TRANSLATION_ALIAS . '.available', ':available')) + ->setParameter('available', true) + ->setParameter('locale', $locale) + ->setMaxResults(1) + ->setCacheable(true); + + $query = $qb->getQuery(); + $query->enableResultCache(120, 'findOneAvailableByLocaleOrOverrideLocale_' . $locale); + + return $query->getOneOrNullResult(); + } + + /** + * Get one available translation by locale. + * + * @param string $locale + * + * @return TranslationInterface|null + * @throws NonUniqueResultException + */ + public function findOneByLocaleAndAvailable(string $locale): ?TranslationInterface + { + $qb = $this->createQueryBuilder(self::TRANSLATION_ALIAS); + $qb->andWhere($qb->expr()->eq(self::TRANSLATION_ALIAS . '.available', ':available')) + ->andWhere($qb->expr()->eq(self::TRANSLATION_ALIAS . '.locale', ':locale')) + ->setParameter('available', true) + ->setParameter('locale', $locale) + ->setMaxResults(1) + ->setCacheable(true); + + $query = $qb->getQuery(); + $query->enableResultCache(120, 'RZTranslationOneByLocaleAndAvailable-' . $locale); + + return $query->getOneOrNullResult(); + } + + /** + * Get one available translation by overrideLocale. + * + * @param string $overrideLocale + * + * @return TranslationInterface|null + * @throws NonUniqueResultException + */ + public function findOneByOverrideLocaleAndAvailable(string $overrideLocale): ?TranslationInterface + { + $qb = $this->createQueryBuilder(self::TRANSLATION_ALIAS); + $qb->andWhere($qb->expr()->eq(self::TRANSLATION_ALIAS . '.available', ':available')) + ->andWhere($qb->expr()->eq(self::TRANSLATION_ALIAS . '.overrideLocale', ':overrideLocale')) + ->setParameter('available', true) + ->setParameter('overrideLocale', $overrideLocale) + ->setMaxResults(1) + ->setCacheable(true); + + $query = $qb->getQuery(); + $query->enableResultCache( + 120, + 'RZTranslationOneByOverrideAndAvailable-' . $overrideLocale + ); + + return $query->getOneOrNullResult(); + } + + /** + * @param Node $node + * @return TranslationInterface[] + */ + public function findAvailableTranslationsForNode(Node $node): array + { + $qb = $this->createQueryBuilder(self::TRANSLATION_ALIAS); + $qb->innerJoin('t.nodeSources', self::NODESSOURCES_ALIAS) + ->andWhere($qb->expr()->eq(self::NODESSOURCES_ALIAS . '.node', ':node')) + ->addOrderBy('t.defaultTranslation', 'DESC') + ->addOrderBy('t.locale', 'ASC') + ->setParameter('node', $node) + ->setCacheable(true); + + return $qb->getQuery()->getResult(); + } + + /** + * @param Tag $tag + * @return TranslationInterface[] + */ + public function findAvailableTranslationsForTag(Tag $tag): array + { + $qb = $this->createQueryBuilder(self::TRANSLATION_ALIAS); + $qb->innerJoin('t.tagTranslations', 'tt') + ->andWhere($qb->expr()->eq('tt.tag', ':tag')) + ->addOrderBy('t.defaultTranslation', 'DESC') + ->addOrderBy('t.locale', 'ASC') + ->setParameter('tag', $tag); + + return $qb->getQuery()->getResult(); + } + + /** + * @param Folder $folder + * @return TranslationInterface[] + */ + public function findAvailableTranslationsForFolder(Folder $folder): array + { + $qb = $this->createQueryBuilder(self::TRANSLATION_ALIAS); + $qb->innerJoin('t.folderTranslations', 'ft') + ->andWhere($qb->expr()->eq('ft.folder', ':folder')) + ->addOrderBy('t.defaultTranslation', 'DESC') + ->addOrderBy('t.locale', 'ASC') + ->setParameter('folder', $folder); + + return $qb->getQuery()->getResult(); + } + + /** + * Find available node translations which are available too. + * + * @param Node $node + * @return TranslationInterface[] + */ + public function findStrictlyAvailableTranslationsForNode(Node $node): array + { + $qb = $this->createQueryBuilder(self::TRANSLATION_ALIAS); + $qb->innerJoin('t.nodeSources', self::NODESSOURCES_ALIAS) + ->andWhere($qb->expr()->eq(self::NODESSOURCES_ALIAS . '.node', ':node')) + ->andWhere($qb->expr()->eq(self::TRANSLATION_ALIAS . '.available', ':available')) + ->addOrderBy('t.defaultTranslation', 'DESC') + ->addOrderBy('t.locale', 'ASC') + ->setParameter('node', $node) + ->setParameter('available', true) + ->setCacheable(true); + + return $qb->getQuery()->getResult(); + } + + + /** + * @param Node $node + * @return TranslationInterface[] + */ + public function findUnavailableTranslationsForNode(Node $node): array + { + $qb = $this->createQueryBuilder(self::TRANSLATION_ALIAS); + $qb->andWhere($qb->expr()->notIn('t.id', ':translationsId')) + ->setParameter('translationsId', $this->findAvailableTranslationIdForNode($node)) + ->setCacheable(true); + + return $qb->getQuery()->getResult(); + } + + /** + * @param Node $node + * @return array + */ + public function findAvailableTranslationIdForNode(Node $node): array + { + $qb = $this->createQueryBuilder(self::TRANSLATION_ALIAS); + $qb->select(self::TRANSLATION_ALIAS . '.id') + ->innerJoin('t.nodeSources', self::NODESSOURCES_ALIAS) + ->andWhere($qb->expr()->eq(self::NODESSOURCES_ALIAS . '.node', ':node')) + ->addOrderBy('t.defaultTranslation', 'DESC') + ->addOrderBy('t.locale', 'ASC') + ->setParameter('node', $node) + ->setCacheable(true); + + return array_map('current', $qb->getQuery()->getScalarResult()); + } + + /** + * @param Node $node + * @return array + */ + public function findUnavailableTranslationIdForNode(Node $node): array + { + $qb = $this->createQueryBuilder(self::TRANSLATION_ALIAS); + $qb->select(self::TRANSLATION_ALIAS . '.id') + ->andWhere($qb->expr()->notIn('t.id', ':translationsId')) + ->setParameter('translationsId', $this->findAvailableTranslationIdForNode($node)) + ->setCacheable(true); + + return array_map('current', $qb->getQuery()->getScalarResult()); + } +} diff --git a/lib/RoadizCoreBundle/src/Repository/UrlAliasRepository.php b/lib/RoadizCoreBundle/src/Repository/UrlAliasRepository.php new file mode 100644 index 00000000..f9728c08 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Repository/UrlAliasRepository.php @@ -0,0 +1,61 @@ + + */ +final class UrlAliasRepository extends EntityRepository +{ + public function __construct( + ManagerRegistry $registry, + EventDispatcherInterface $dispatcher + ) { + parent::__construct($registry, UrlAlias::class, $dispatcher); + } + + /** + * Get all url aliases linked to given node. + * + * @param integer $nodeId + * + * @return array + */ + public function findAllFromNode($nodeId) + { + $query = $this->_em->createQuery(' + SELECT ua FROM RZ\Roadiz\CoreBundle\Entity\UrlAlias ua + INNER JOIN ua.nodeSource ns + INNER JOIN ns.node n + WHERE n.id = :nodeId') + ->setParameter('nodeId', (int) $nodeId); + + return $query->getResult(); + } + + /** + * @param string $alias + * + * @return boolean + */ + public function exists($alias) + { + $query = $this->_em->createQuery(' + SELECT COUNT(ua.alias) FROM RZ\Roadiz\CoreBundle\Entity\UrlAlias ua + WHERE ua.alias = :alias') + ->setParameter('alias', $alias); + + return (bool) $query->getSingleScalarResult(); + } +} diff --git a/lib/RoadizCoreBundle/src/Repository/UserLogEntryRepository.php b/lib/RoadizCoreBundle/src/Repository/UserLogEntryRepository.php new file mode 100644 index 00000000..00111b01 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Repository/UserLogEntryRepository.php @@ -0,0 +1,25 @@ + + */ +final class UserLogEntryRepository extends EntityRepository +{ + public function __construct(ManagerRegistry $registry, EventDispatcherInterface $dispatcher) + { + parent::__construct($registry, UserLogEntry::class, $dispatcher); + } +} diff --git a/lib/RoadizCoreBundle/src/Repository/UserRepository.php b/lib/RoadizCoreBundle/src/Repository/UserRepository.php new file mode 100644 index 00000000..efc78acb --- /dev/null +++ b/lib/RoadizCoreBundle/src/Repository/UserRepository.php @@ -0,0 +1,62 @@ + + */ +final class UserRepository extends EntityRepository +{ + public function __construct( + ManagerRegistry $registry, + EventDispatcherInterface $dispatcher + ) { + parent::__construct($registry, User::class, $dispatcher); + } + + /** + * @param string $username + * + * @return bool + * @throws \Doctrine\ORM\NoResultException + * @throws \Doctrine\ORM\NonUniqueResultException + */ + public function usernameExists($username): bool + { + $qb = $this->createQueryBuilder('u'); + $qb->select($qb->expr()->count('u.username')) + ->andWhere($qb->expr()->eq('u.username', ':username')) + ->setParameter('username', $username) + ->setCacheable(true); + + return (bool) $qb->getQuery()->getSingleScalarResult(); + } + + /** + * @param string $email + * @return bool + * @throws \Doctrine\ORM\NoResultException + * @throws \Doctrine\ORM\NonUniqueResultException + */ + public function emailExists(string $email): bool + { + $qb = $this->createQueryBuilder('u'); + $qb->select($qb->expr()->count('u.email')) + ->andWhere($qb->expr()->eq('u.email', ':email')) + ->setParameter('email', $email) + ->setCacheable(true); + + return (bool) $qb->getQuery()->getSingleScalarResult(); + } +} diff --git a/lib/RoadizCoreBundle/src/Repository/WebhookRepository.php b/lib/RoadizCoreBundle/src/Repository/WebhookRepository.php new file mode 100644 index 00000000..7592cea6 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Repository/WebhookRepository.php @@ -0,0 +1,25 @@ + + */ +final class WebhookRepository extends EntityRepository +{ + public function __construct(ManagerRegistry $registry, EventDispatcherInterface $dispatcher) + { + parent::__construct($registry, Webhook::class, $dispatcher); + } +} diff --git a/lib/RoadizCoreBundle/src/RoadizCoreBundle.php b/lib/RoadizCoreBundle/src/RoadizCoreBundle.php new file mode 100644 index 00000000..540b3975 --- /dev/null +++ b/lib/RoadizCoreBundle/src/RoadizCoreBundle.php @@ -0,0 +1,44 @@ +addCompilerPass(new CommonMarkCompilerPass()); + $container->addCompilerPass(new MediaFinderCompilerPass()); + $container->addCompilerPass(new DocumentRendererCompilerPass()); + $container->addCompilerPass(new ImporterCompilerPass()); + $container->addCompilerPass(new NodeWorkflowCompilerPass()); + $container->addCompilerPass(new DoctrineMigrationCompilerPass()); + $container->addCompilerPass(new RateLimitersCompilerPass()); + $container->addCompilerPass(new NodesSourcesEntitiesPathCompilerPass()); + $container->addCompilerPass(new PathResolverCompilerPass()); + $container->addCompilerPass(new FlysystemStorageCompilerPass()); + $container->addCompilerPass(new TwigLoaderCompilerPass()); + } +} diff --git a/lib/RoadizCoreBundle/src/Routing/ChainResourcePathResolver.php b/lib/RoadizCoreBundle/src/Routing/ChainResourcePathResolver.php new file mode 100644 index 00000000..d618ae25 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Routing/ChainResourcePathResolver.php @@ -0,0 +1,44 @@ + + */ + private array $pathResolvers = []; + + public function addPathResolver(PathResolverInterface $pathResolver): ChainResourcePathResolver + { + $this->pathResolvers[get_class($pathResolver)] = $pathResolver; + return $this; + } + + /** + * @inheritDoc + */ + public function resolvePath( + string $path, + array $supportedFormatExtensions = ['html'], + bool $allowRootPaths = false, + bool $allowNonReachableNodes = true + ): ResourceInfo { + if (count($this->pathResolvers) === 0) { + throw new ResourceNotFoundException('No PathResolverInterface was registered to resolve path'); + } + foreach ($this->pathResolvers as $pathResolver) { + try { + return $pathResolver->resolvePath($path, $supportedFormatExtensions, $allowRootPaths, $allowNonReachableNodes); + } catch (ResourceNotFoundException $exception) { + // Do nothing to allow other resolver to work. + } + } + // If none responds, throws ResourceNotFoundException + throw new ResourceNotFoundException('None of the chained PathResolverInterface were able to resolve path'); + } +} diff --git a/lib/RoadizCoreBundle/src/Routing/DeferredRouteCollection.php b/lib/RoadizCoreBundle/src/Routing/DeferredRouteCollection.php new file mode 100644 index 00000000..4597717c --- /dev/null +++ b/lib/RoadizCoreBundle/src/Routing/DeferredRouteCollection.php @@ -0,0 +1,29 @@ +urlGenerator = $urlGenerator; + } + + /** + * @return string + */ + protected function getRouteName(): string + { + return 'interventionRequestProcess'; + } + + protected function getProcessedDocumentUrlByArray(bool $absolute = false): string + { + if (null === $this->getDocument()) { + throw new \InvalidArgumentException('Cannot get URL from a NULL document'); + } + + $referenceType = $absolute ? UrlGeneratorInterface::ABSOLUTE_URL : UrlGeneratorInterface::ABSOLUTE_PATH; + + $routeParams = [ + 'queryString' => $this->optionCompiler->compile($this->options), + 'filename' => $this->getDocument()->getRelativePath(), + ]; + + return $this->urlGenerator->generate( + $this->getRouteName(), + $routeParams, + $referenceType + ); + } +} diff --git a/lib/RoadizCoreBundle/src/Routing/DynamicUrlMatcher.php b/lib/RoadizCoreBundle/src/Routing/DynamicUrlMatcher.php new file mode 100644 index 00000000..de55d665 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Routing/DynamicUrlMatcher.php @@ -0,0 +1,42 @@ +stopwatch = $stopwatch; + $this->logger = $logger ?? new NullLogger(); + $this->previewResolver = $previewResolver; + } +} diff --git a/lib/RoadizCoreBundle/src/Routing/InstallRouteCollection.php b/lib/RoadizCoreBundle/src/Routing/InstallRouteCollection.php new file mode 100644 index 00000000..7c86ff93 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Routing/InstallRouteCollection.php @@ -0,0 +1,36 @@ +installClassname = $installClassname; + } + + /** + * {@inheritdoc} + */ + public function parseResources(): void + { + if (class_exists($this->installClassname)) { + $collection = call_user_func([$this->installClassname, 'getRoutes']); + if (null !== $collection) { + $this->addCollection($collection); + } + } else { + throw new \RuntimeException("Install class “" . $this->installClassname . "” does not exist.", 1); + } + } +} diff --git a/lib/RoadizCoreBundle/src/Routing/NodePathInfo.php b/lib/RoadizCoreBundle/src/Routing/NodePathInfo.php new file mode 100644 index 00000000..9ee32964 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Routing/NodePathInfo.php @@ -0,0 +1,136 @@ +path; + } + + /** + * @param string $path + * + * @return NodePathInfo + */ + public function setPath(string $path): NodePathInfo + { + $this->path = $path; + + return $this; + } + + /** + * @return array + */ + public function getParameters(): array + { + return $this->parameters; + } + + /** + * @param array $parameters + * + * @return NodePathInfo + */ + public function setParameters(array $parameters): NodePathInfo + { + $this->parameters = $parameters; + + return $this; + } + + /** + * @return bool + */ + public function isComplete(): bool + { + return $this->isComplete; + } + + /** + * @param bool $isComplete + * + * @return NodePathInfo + */ + public function setComplete(bool $isComplete): NodePathInfo + { + $this->isComplete = $isComplete; + + return $this; + } + + /** + * @return bool + */ + public function containsScheme(): bool + { + return $this->containsScheme; + } + + /** + * @param bool $containsScheme + * + * @return NodePathInfo + */ + public function setContainsScheme(bool $containsScheme): NodePathInfo + { + $this->containsScheme = $containsScheme; + + return $this; + } + + /** + * @deprecated Use __serialize + */ + public function serialize(): string + { + return \json_encode([ + 'path' => $this->getPath(), + 'parameters' => $this->getParameters(), + 'is_complete' => $this->isComplete(), + 'contains_scheme' => $this->containsScheme() + ]); + } + + public function __serialize(): array + { + return [ + 'path' => $this->getPath(), + 'parameters' => $this->getParameters(), + 'is_complete' => $this->isComplete(), + 'contains_scheme' => $this->containsScheme() + ]; + } + + /** + * @deprecated Use __unserialize + */ + public function unserialize(string $serialized): void + { + $data = \json_decode($serialized, true); + $this->setComplete($data['is_complete']); + $this->setParameters($data['parameters']); + $this->setPath($data['path']); + $this->setContainsScheme($data['contains_scheme']); + } + + public function __unserialize(array $data): void + { + $this->setComplete($data['is_complete']); + $this->setParameters($data['parameters']); + $this->setPath($data['path']); + $this->setContainsScheme($data['contains_scheme']); + } +} diff --git a/lib/RoadizCoreBundle/src/Routing/NodeRouteHelper.php b/lib/RoadizCoreBundle/src/Routing/NodeRouteHelper.php new file mode 100644 index 00000000..f51117d2 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Routing/NodeRouteHelper.php @@ -0,0 +1,141 @@ + $defaultControllerClass + * @param string $defaultControllerNamespace + */ + public function __construct( + Node $node, + ?Theme $theme, + PreviewResolverInterface $previewResolver, + LoggerInterface $logger, + string $defaultControllerClass, + string $defaultControllerNamespace = '\\App\\Controller' + ) { + $this->node = $node; + $this->theme = $theme; + $this->previewResolver = $previewResolver; + $this->defaultControllerClass = $defaultControllerClass; + $this->logger = $logger; + $this->defaultControllerNamespace = $defaultControllerNamespace; + } + + /** + * Get controller class path for a given node. + * + * @return string + */ + public function getController(): string + { + if (null === $this->controller) { + $namespace = $this->getControllerNamespace(); + $this->controller = $namespace . '\\' . + StringHandler::classify($this->node->getNodeType()->getName()) . + 'Controller'; + + /* + * Use a default controller if no controller was found in Theme. + */ + if (!class_exists($this->controller) && $this->node->getNodeType()->isReachable()) { + $this->controller = $this->defaultControllerClass; + } + } + + return $this->controller; + } + + protected function getControllerNamespace(): string + { + $namespace = $this->defaultControllerNamespace; + if (null !== $this->theme) { + $refl = new \ReflectionClass($this->theme->getClassName()); + $namespace = $refl->getNamespaceName() . '\\Controllers'; + } + return $namespace; + } + + public function getMethod(): string + { + return 'indexAction'; + } + + /** + * Return FALSE or TRUE if node is viewable. + * + * @return bool + * @throws \ReflectionException + */ + public function isViewable(): bool + { + if (!class_exists($this->getController())) { + $this->logger->debug($this->getController() . ' controller does not exist.'); + return false; + } + if (!method_exists($this->getController(), $this->getMethod())) { + $this->logger->debug( + $this->getController() . ':' . + $this->getMethod() . ' controller method does not exist.' + ); + return false; + } + /* + * For archived and deleted nodes + */ + if ($this->node->getStatus() > Node::PUBLISHED) { + /* + * Not allowed to see deleted and archived nodes + * even for Admins + */ + return false; + } + + /* + * For unpublished nodes + */ + if ($this->node->getStatus() < Node::PUBLISHED) { + if (true === $this->previewResolver->isPreview()) { + return true; + } + /* + * Not allowed to see unpublished nodes + */ + return false; + } + + /* + * Everyone can view published nodes. + */ + return true; + } +} diff --git a/lib/RoadizCoreBundle/src/Routing/NodeRouter.php b/lib/RoadizCoreBundle/src/Routing/NodeRouter.php new file mode 100644 index 00000000..bdea3b74 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Routing/NodeRouter.php @@ -0,0 +1,307 @@ +settingsBag = $settingsBag; + $this->eventDispatcher = $eventDispatcher; + $this->matcher = $matcher; + $this->nodeSourceUrlCacheAdapter = $nodeSourceUrlCacheAdapter; + } + + /** + * {@inheritdoc} + */ + public function getRouteCollection(): RouteCollection + { + return new RouteCollection(); + } + + /** + * Gets the UrlMatcher instance associated with this Router. + * + * @return UrlMatcherInterface + */ + public function getMatcher(): UrlMatcherInterface + { + return $this->matcher; + } + + /** + * No generator for a node router. + */ + public function getGenerator(): UrlGeneratorInterface + { + throw new \BadMethodCallException(get_class($this) . ' does not support path generation.'); + } + + /** + * @inheritDoc + */ + public function supports($name): bool + { + return ($name instanceof NodesSources || $name === RouteObjectInterface::OBJECT_BASED_ROUTE_NAME); + } + + /** + * @return Theme|null + */ + public function getTheme(): ?Theme + { + return $this->theme; + } + + /** + * @param Theme|null $theme + * @return NodeRouter + */ + public function setTheme(?Theme $theme): NodeRouter + { + $this->theme = $theme; + return $this; + } + + /** + * Convert a route identifier (name, content object etc) into a string + * usable for logging and other debug/error messages + * + * @param mixed $name + * @param array $parameters which should contain a content field containing + * a RouteReferrersReadInterface object + * + * @return string + */ + public function getRouteDebugMessage($name, array $parameters = []): string + { + if ($name instanceof NodesSources) { + @trigger_error('Passing an object as route name is deprecated since version 1.5. Pass the `RouteObjectInterface::OBJECT_BASED_ROUTE_NAME` as route name and the object in the parameters with key `RouteObjectInterface::ROUTE_OBJECT` resp the content id with content_id.', E_USER_DEPRECATED); + return '[' . $name->getTranslation()->getLocale() . ']' . + $name->getTitle() . ' - ' . + $name->getNode()->getNodeName() . + '[' . $name->getNode()->getId() . ']'; + } elseif (RouteObjectInterface::OBJECT_BASED_ROUTE_NAME === $name) { + if ( + array_key_exists(RouteObjectInterface::ROUTE_OBJECT, $parameters) && + $parameters[RouteObjectInterface::ROUTE_OBJECT] instanceof NodesSources + ) { + $route = $parameters[RouteObjectInterface::ROUTE_OBJECT]; + return '[' . $route->getTranslation()->getLocale() . ']' . + $route->getTitle() . ' - ' . + $route->getNode()->getNodeName() . + '[' . $route->getNode()->getId() . ']'; + } + } + return (string) $name; + } + + /** + * {@inheritdoc} + */ + public function generate(string $name, array $parameters = [], int $referenceType = self::ABSOLUTE_PATH): string + { + if (RouteObjectInterface::OBJECT_BASED_ROUTE_NAME === $name) { + if ( + array_key_exists(RouteObjectInterface::ROUTE_OBJECT, $parameters) && + $parameters[RouteObjectInterface::ROUTE_OBJECT] instanceof NodesSources + ) { + $route = $parameters[RouteObjectInterface::ROUTE_OBJECT]; + unset($parameters[RouteObjectInterface::ROUTE_OBJECT]); + } else { + $route = null; + } + } else { + $route = null; + } + + if (!$route instanceof NodesSources) { + throw new RouteNotFoundException(); + } + + if (!empty($parameters['canonicalScheme'])) { + $schemeAuthority = trim($parameters['canonicalScheme']); + unset($parameters['canonicalScheme']); + } else { + $schemeAuthority = $this->getContext()->getScheme() . '://' . $this->getHttpHost(); + } + + $noCache = false; + if (!empty($parameters[static::NO_CACHE_PARAMETER])) { + $noCache = (bool)($parameters[static::NO_CACHE_PARAMETER]); + } + + $nodePathInfo = $this->getResourcePath($route, $parameters, $noCache); + + /* + * If node path is complete, do not alter path anymore. + */ + if (true === $nodePathInfo->isComplete()) { + if ($referenceType == self::ABSOLUTE_URL && !$nodePathInfo->containsScheme()) { + return $schemeAuthority . $nodePathInfo->getPath(); + } + return $nodePathInfo->getPath(); + } + + $queryString = ''; + $parameters = $nodePathInfo->getParameters(); + $matcher = $this->getMatcher(); + + if ( + isset($parameters['_format']) && + $matcher instanceof NodeUrlMatcher && + in_array($parameters['_format'], $matcher->getSupportedFormatExtensions()) + ) { + unset($parameters['_format']); + } + if (array_key_exists(static::NO_CACHE_PARAMETER, $parameters)) { + unset($parameters[static::NO_CACHE_PARAMETER]); + } + if (count($parameters) > 0) { + $queryString = '?' . http_build_query($parameters); + } + + if ($referenceType == self::ABSOLUTE_URL) { + // Absolute path + return $schemeAuthority . $this->getContext()->getBaseUrl() . '/' . $nodePathInfo->getPath() . $queryString; + } + + // ABSOLUTE_PATH + return $this->getContext()->getBaseUrl() . '/' . $nodePathInfo->getPath() . $queryString; + } + + /** + * @param NodesSources $source + * @param array $parameters + * @param bool $noCache + * + * @return NodePathInfo + * @throws \Psr\Cache\InvalidArgumentException + */ + protected function getResourcePath( + NodesSources $source, + array $parameters = [], + bool $noCache = false + ): NodePathInfo { + if ($noCache) { + $parametersHash = sha1(serialize($parameters)); + $cacheKey = 'ns_url_' . $source->getId() . '_' . $this->getContext()->getHost() . '_' . $parametersHash; + $cacheItem = $this->nodeSourceUrlCacheAdapter->getItem($cacheKey); + if (!$cacheItem->isHit()) { + $cacheItem->set($this->getNodesSourcesPath($source, $parameters)); + $this->nodeSourceUrlCacheAdapter->save($cacheItem); + } + return $cacheItem->get(); + } + + return $this->getNodesSourcesPath($source, $parameters); + } + + /** + * @param NodesSources $source + * @param array $parameters + * + * @return NodePathInfo + */ + protected function getNodesSourcesPath(NodesSources $source, array $parameters = []): NodePathInfo + { + $event = new NodesSourcesPathGeneratingEvent( + $this->getTheme(), + $source, + $this->getContext(), + $parameters, + (bool) $this->settingsBag->get('force_locale'), + (bool) $this->settingsBag->get('force_locale_with_urlaliases') + ); + /* + * Dispatch node-source URL generation to any listener + */ + $this->eventDispatcher->dispatch($event); + /* + * Get path, parameters and isComplete back from event propagation. + */ + $nodePathInfo = new NodePathInfo(); + $nodePathInfo->setPath($event->getPath()); + $nodePathInfo->setParameters($event->getParameters()); + $nodePathInfo->setComplete($event->isComplete()); + $nodePathInfo->setContainsScheme($event->containsScheme()); + + if (null === $nodePathInfo->getPath()) { + throw new InvalidParameterException('NodeSource generated path is null.'); + } + return $nodePathInfo; + } + + /** + * Returns the HTTP host being requested. + * + * The port name will be appended to the host if it's non-standard. + * + * @return string + */ + private function getHttpHost(): string + { + $scheme = $this->getContext()->getScheme(); + + $port = ''; + if ('http' === $scheme && 80 != $this->context->getHttpPort()) { + $port = ':' . $this->context->getHttpPort(); + } elseif ('https' === $scheme && 443 != $this->context->getHttpsPort()) { + $port = ':' . $this->context->getHttpsPort(); + } + + return $this->getContext()->getHost() . $port; + } +} diff --git a/lib/RoadizCoreBundle/src/Routing/NodeUrlMatcher.php b/lib/RoadizCoreBundle/src/Routing/NodeUrlMatcher.php new file mode 100644 index 00000000..0ef42348 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Routing/NodeUrlMatcher.php @@ -0,0 +1,128 @@ +pathResolver = $pathResolver; + $this->defaultControllerClass = $defaultControllerClass; + } + + /** + * {@inheritdoc} + */ + public function match(string $pathinfo): array + { + $decodedUrl = rawurldecode($pathinfo); + /* + * Try nodes routes + */ + return $this->matchNode($decodedUrl, null); + } + + protected function getNodeRouteHelper(NodesSources $nodeSource, ?Theme $theme): NodeRouteHelper + { + return new NodeRouteHelper( + $nodeSource->getNode(), + $theme, + $this->previewResolver, + $this->logger, + $this->defaultControllerClass + ); + } + + /** + * @param string $decodedUrl + * @param Theme|null $theme + * @return array + * @throws \ReflectionException + */ + public function matchNode(string $decodedUrl, ?Theme $theme): array + { + $resourceInfo = $this->pathResolver->resolvePath( + $decodedUrl, + $this->getSupportedFormatExtensions() + ); + $nodeSource = $resourceInfo->getResource(); + + if ($nodeSource instanceof NodesSources && !$nodeSource->getNode()->isHome()) { + $translation = $nodeSource->getTranslation(); + $nodeRouteHelper = $this->getNodeRouteHelper($nodeSource, $theme); + + if (!$this->previewResolver->isPreview() && !$translation->isAvailable()) { + throw new ResourceNotFoundException(); + } + + if (false === $nodeRouteHelper->isViewable()) { + throw new ResourceNotFoundException(); + } + + return [ + '_controller' => $nodeRouteHelper->getController() . '::' . $nodeRouteHelper->getMethod(), + '_locale' => $resourceInfo->getLocale(), + '_route' => RouteObjectInterface::OBJECT_BASED_ROUTE_NAME, + '_format' => $resourceInfo->getFormat(), + 'node' => $nodeSource->getNode(), + 'nodeSource' => $nodeSource, + RouteObjectInterface::ROUTE_OBJECT => $resourceInfo->getResource(), + 'translation' => $resourceInfo->getTranslation(), + 'theme' => $theme, + ]; + } + throw new ResourceNotFoundException(); + } +} diff --git a/lib/RoadizCoreBundle/src/Routing/NodeUrlMatcherInterface.php b/lib/RoadizCoreBundle/src/Routing/NodeUrlMatcherInterface.php new file mode 100644 index 00000000..923254d8 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Routing/NodeUrlMatcherInterface.php @@ -0,0 +1,21 @@ + + */ + public function getSupportedFormatExtensions(): array; + + public function getDefaultSupportedFormatExtension(): string; + + public function matchNode(string $decodedUrl, ?Theme $theme): array; +} diff --git a/lib/RoadizCoreBundle/src/Routing/NodesSourcesPathAggregator.php b/lib/RoadizCoreBundle/src/Routing/NodesSourcesPathAggregator.php new file mode 100644 index 00000000..5f6942de --- /dev/null +++ b/lib/RoadizCoreBundle/src/Routing/NodesSourcesPathAggregator.php @@ -0,0 +1,12 @@ +stopwatch = $stopwatch; + $this->previewResolver = $previewResolver; + $this->managerRegistry = $managerRegistry; + $this->settingsBag = $settingsBag; + $this->requestStack = $requestStack; + $this->useAcceptLanguageHeader = $useAcceptLanguageHeader; + } + + /** + * @inheritDoc + */ + public function resolvePath( + string $path, + array $supportedFormatExtensions = ['html'], + bool $allowRootPaths = false, + bool $allowNonReachableNodes = true + ): ResourceInfo { + $resourceInfo = new ResourceInfo(); + $tokens = $this->tokenizePath($path); + $_format = 'html'; + + if (count($tokens) === 0 && !$allowRootPaths) { + throw new ResourceNotFoundException(); + } + + if ($path === '/') { + $this->stopwatch->start('parseRootPath'); + $translation = $this->parseTranslation(); + $nodeSource = $this->getHome($translation); + $this->stopwatch->stop('parseRootPath'); + } else { + $identifier = ''; + if (count($tokens) > 0) { + $identifier = strip_tags($tokens[(int) (count($tokens) - 1)]); + } + + if ($identifier !== '') { + /* + * Prevent searching nodes with special characters. + */ + if (0 === preg_match('#' . static::$nodeNamePattern . '#', $identifier)) { + throw new ResourceNotFoundException(); + } + + /* + * Look for any supported format extension after last token. + */ + if ( + 0 !== preg_match( + '#^(' . static::$nodeNamePattern . ')\.(' . implode('|', $supportedFormatExtensions) . ')$#', + $identifier, + $matches + ) + ) { + $realIdentifier = $matches[1]; + $_format = $matches[2]; + // replace last token with real node-name without extension. + $tokens[(int) (count($tokens) - 1)] = $realIdentifier; + } + } + + $this->stopwatch->start('parseTranslation'); + $translation = $this->parseTranslation($tokens); + $this->stopwatch->stop('parseTranslation'); + /* + * Try with URL Aliases OR nodeName + */ + $this->stopwatch->start('parseFromIdentifier'); + $nodeSource = $this->parseFromIdentifier($tokens, $translation, $allowNonReachableNodes); + $this->stopwatch->stop('parseFromIdentifier'); + } + + if (null === $nodeSource) { + throw new ResourceNotFoundException(); + } + + $resourceInfo->setResource($nodeSource); + $resourceInfo->setTranslation($nodeSource->getTranslation()); + $resourceInfo->setFormat($_format); + $resourceInfo->setLocale($nodeSource->getTranslation()->getPreferredLocale()); + return $resourceInfo; + } + + /** + * Split path into meaningful tokens. + * + * @param string $path + * @return array + */ + private function tokenizePath(string $path): array + { + $tokens = explode('/', $path); + $tokens = array_values(array_filter($tokens)); + + return $tokens; + } + + /** + * @param TranslationInterface $translation + * @return NodesSources|null + */ + private function getHome(TranslationInterface $translation): ?NodesSources + { + /** + * Resolve home page + * @phpstan-ignore-next-line + */ + return $this->managerRegistry + ->getRepository(NodesSources::class) + ->findOneBy([ + 'node.home' => true, + 'translation' => $translation + ]); + } + + /** + * Parse translation from URL tokens even if it is not available yet. + * + * @param array $tokens + * + * @return TranslationInterface|null + */ + private function parseTranslation(array &$tokens = []): ?TranslationInterface + { + /** @var TranslationRepository $repository */ + $repository = $this->managerRegistry->getRepository(Translation::class); + $findOneByMethod = $this->previewResolver->isPreview() ? + 'findOneByLocaleOrOverrideLocale' : + 'findOneAvailableByLocaleOrOverrideLocale'; + + if (!empty($tokens[0])) { + $firstToken = $tokens[0]; + $locale = mb_strtolower(strip_tags((string) $firstToken)); + // First token is for language and should not exceed 11 chars, i.e. tzm-Latn-DZ + if ($locale !== null && $locale != '' && mb_strlen($locale) <= 11) { + $translation = $repository->$findOneByMethod($locale); + if (null !== $translation) { + return $translation; + } elseif (in_array($tokens[0], Translation::getAvailableLocales())) { + throw new ResourceNotFoundException(sprintf('"%s" translation was not found.', $tokens[0])); + } + } + } + + if ( + $this->useAcceptLanguageHeader && + $this->settingsBag->get('force_locale', false) === true + ) { + /* + * When no information to find locale is found and "force_locale" is ON, + * we must find translation based on Accept-Language header. + * Be careful if you are using a reverse-proxy cache, YOU MUST VARY ON Accept-Language header. + * @see https://varnish-cache.org/docs/6.3/users-guide/increasing-your-hitrate.html#http-vary + */ + $request = $this->requestStack->getMainRequest(); + if ( + null !== $request && + null !== $preferredLocale = $request->getPreferredLanguage($repository->getAvailableLocales()) + ) { + $translation = $repository->$findOneByMethod($preferredLocale); + if (null !== $translation) { + return $translation; + } + } + } + + return $repository->findDefault(); + } + + /** + * @param array $tokens + * @param TranslationInterface|null $translation + * @param bool $allowNonReachableNodes + * @return NodesSources|null + */ + private function parseFromIdentifier( + array &$tokens, + ?TranslationInterface $translation = null, + bool $allowNonReachableNodes = true + ): ?NodesSources { + if (!empty($tokens[0])) { + /* + * If the only url token is not for language + */ + if (count($tokens) > 1 || !in_array($tokens[0], Translation::getAvailableLocales())) { + $identifier = mb_strtolower(strip_tags($tokens[(int) (count($tokens) - 1)])); + if ($identifier !== null && $identifier != '') { + $array = $this->managerRegistry + ->getRepository(Node::class) + ->findNodeTypeNameAndSourceIdByIdentifier( + $identifier, + $translation, + !$this->previewResolver->isPreview(), + $allowNonReachableNodes + ); + if (null !== $array) { + /** @var NodesSources|null $nodeSource */ + $nodeSource = $this->managerRegistry + ->getRepository($this->getNodeTypeClassname($array['name'])) + ->findOneBy([ + 'id' => $array['id'] + ]); + return $nodeSource; + } else { + throw new ResourceNotFoundException(sprintf('"%s" was not found.', $identifier)); + } + } else { + throw new ResourceNotFoundException(); + } + } + } + + return $this->getHome($translation); + } + + /** + * @param string $name + * @return class-string + */ + private function getNodeTypeClassname(string $name): string + { + $fqcn = NodeType::getGeneratedEntitiesNamespace() . '\\NS' . ucwords($name); + if (!class_exists($fqcn)) { + throw new ResourceNotFoundException($fqcn . ' entity does not exist.'); + } + return $fqcn; + } +} diff --git a/lib/RoadizCoreBundle/src/Routing/NodesSourcesUrlGenerator.php b/lib/RoadizCoreBundle/src/Routing/NodesSourcesUrlGenerator.php new file mode 100644 index 00000000..293d5e5e --- /dev/null +++ b/lib/RoadizCoreBundle/src/Routing/NodesSourcesUrlGenerator.php @@ -0,0 +1,151 @@ +pathAggregator = $pathAggregator; + $this->request = $request; + $this->nodeSource = $nodeSource; + $this->forceLocale = $forceLocale; + $this->forceLocaleWithUrlAlias = $forceLocaleWithUrlAlias; + } + + /** + * @param NodesSources $nodeSource + * @return bool + */ + protected function isNodeSourceHome(NodesSources $nodeSource): bool + { + if ($nodeSource->getNode()->isHome()) { + return true; + } + + return false; + } + + /** + * Return a NodesSources url without hostname and without + * root folder. + * + * It returns a relative url to Roadiz, not relative to your server root. + * + * @param Theme|null $theme + * @param array $parameters + * + * @return string + */ + public function getNonContextualUrl(?Theme $theme = null, array $parameters = []): string + { + if (null !== $this->nodeSource) { + if ($this->isNodeSourceHome($this->nodeSource)) { + if ( + $this->nodeSource->getTranslation()->isDefaultTranslation() && + false === $this->forceLocale + ) { + return ''; + } else { + return $this->nodeSource->getTranslation()->getPreferredLocale(); + } + } + + $path = $this->pathAggregator->aggregatePath($this->nodeSource, $parameters); + + /* + * If using node-name, we must use shortLocale when current + * translation is not the default one. + */ + if ($this->urlNeedsLocalePrefix($this->nodeSource)) { + $path = $this->nodeSource->getTranslation()->getPreferredLocale() . '/' . $path; + } + + if (null !== $theme && $theme->getRoutePrefix() != '') { + $path = $theme->getRoutePrefix() . '/' . $path; + } + /* + * Add non default format at the path end. + */ + if (isset($parameters['_format']) && in_array($parameters['_format'], ['xml', 'json', 'pdf'])) { + $path .= '.' . $parameters['_format']; + } + + return $path; + } else { + throw new \RuntimeException("Cannot generate Url for a NULL NodesSources", 1); + } + } + + /** + * @param NodesSources $nodesSources + * + * @return bool + */ + protected function useUrlAlias(NodesSources $nodesSources): bool + { + if ($nodesSources->getIdentifier() !== $nodesSources->getNode()->getNodeName()) { + return true; + } + + return false; + } + + /** + * @param NodesSources $nodesSources + * + * @return bool + */ + protected function urlNeedsLocalePrefix(NodesSources $nodesSources): bool + { + /* + * Needs a prefix only if translation is not default AND nodeSource does not have an Url alias + * for this translation. + * Of course we force prefix if admin said so… + * Or we can force prefix only when we use urlAliases + */ + if ( + ( + !$this->useUrlAlias($nodesSources) && + !$nodesSources->getTranslation()->isDefaultTranslation() + ) || + ( + $this->useUrlAlias($nodesSources) && + !$nodesSources->getTranslation()->isDefaultTranslation() && + true === $this->forceLocaleWithUrlAlias + ) || + true === $this->forceLocale + ) { + return true; + } + + return false; + } +} diff --git a/lib/RoadizCoreBundle/src/Routing/NullLoader.php b/lib/RoadizCoreBundle/src/Routing/NullLoader.php new file mode 100644 index 00000000..70eaacd3 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Routing/NullLoader.php @@ -0,0 +1,59 @@ +managerRegistry = $managerRegistry; + $this->cacheAdapter = $cacheAdapter; + } + + private function getCacheKey(NodesSources $nodesSources): string + { + return 'ns_url_' . $nodesSources->getId(); + } + + /** + * @param NodesSources $nodesSources + * @param array $parameters + * @return string + */ + public function aggregatePath(NodesSources $nodesSources, array $parameters = []): string + { + if ( + isset($parameters[NodeRouter::NO_CACHE_PARAMETER]) && + $parameters[NodeRouter::NO_CACHE_PARAMETER] === true + ) { + $urlTokens = array_reverse($this->getIdentifiers($nodesSources)); + return implode('/', $urlTokens); + } + + $cacheItem = $this->cacheAdapter->getItem($this->getCacheKey($nodesSources)); + if (!$cacheItem->isHit()) { + $urlTokens = array_reverse($this->getIdentifiers($nodesSources)); + $cacheItem->set(implode('/', $urlTokens)); + $this->cacheAdapter->save($cacheItem); + } + return $cacheItem->get(); + } + + /** + * @param Node $parent + * + * @return array + */ + protected function getParentsIds(Node $parent): array + { + $parentIds = []; + while ($parent !== null && !$parent->isHome()) { + $parentIds[] = $parent->getId(); + $parent = $parent->getParent(); + } + + return $parentIds; + } + + /** + * Get every nodeSource parents identifier from current to + * farest ancestor. + * + * @param NodesSources $source + * + * @return array + */ + protected function getIdentifiers(NodesSources $source): array + { + $urlTokens = []; + $parents = []; + /** @var Node|null $parentNode */ + $parentNode = $source->getNode()->getParent(); + + if (null !== $parentNode) { + $parentIds = $this->getParentsIds($parentNode); + if (count($parentIds) > 0) { + /** + * + * Do a partial query to optimize SQL time + */ + $qb = $this->managerRegistry + ->getRepository(NodesSources::class) + ->createQueryBuilder('ns'); + $parents = $qb->select('n.id as id, n.nodeName as nodeName, ua.alias as alias') + ->innerJoin('ns.node', 'n') + ->leftJoin('ns.urlAliases', 'ua') + ->andWhere($qb->expr()->in('n.id', ':parentIds')) + ->andWhere($qb->expr()->eq('n.visible', ':visible')) + ->andWhere($qb->expr()->eq('ns.translation', ':translation')) + ->setParameters([ + 'parentIds' => $parentIds, + 'visible' => true, + 'translation' => $source->getTranslation() + ]) + ->getQuery() + ->setHint(Query::HINT_FORCE_PARTIAL_LOAD, true) + ->setCacheable(true) + ->getArrayResult() + ; + usort($parents, function ($a, $b) use ($parentIds) { + return array_search($a['id'], $parentIds) - + array_search($b['id'], $parentIds); + }); + } + } + + $urlTokens[] = $source->getIdentifier(); + + foreach ($parents as $parent) { + $urlTokens[] = $parent['alias'] ?? $parent['nodeName']; + } + + return $urlTokens; + } +} diff --git a/lib/RoadizCoreBundle/src/Routing/PathResolverInterface.php b/lib/RoadizCoreBundle/src/Routing/PathResolverInterface.php new file mode 100644 index 00000000..45b3429c --- /dev/null +++ b/lib/RoadizCoreBundle/src/Routing/PathResolverInterface.php @@ -0,0 +1,24 @@ + $supportedFormatExtensions + * @param bool $allowRootPaths Allow resolving / and /en, /fr paths to home pages + * @param bool $allowNonReachableNodes Allow resolving non-reachable nodes + * @return ResourceInfo + */ + public function resolvePath( + string $path, + array $supportedFormatExtensions = ['html'], + bool $allowRootPaths = false, + bool $allowNonReachableNodes = true + ): ResourceInfo; +} diff --git a/lib/RoadizCoreBundle/src/Routing/RedirectableUrlMatcher.php b/lib/RoadizCoreBundle/src/Routing/RedirectableUrlMatcher.php new file mode 100644 index 00000000..a686e08c --- /dev/null +++ b/lib/RoadizCoreBundle/src/Routing/RedirectableUrlMatcher.php @@ -0,0 +1,33 @@ + RedirectionController::class . '::redirectToRouteAction', + 'path' => $path, + 'permanent' => true, + 'scheme' => $scheme, + 'httpPort' => $this->context->getHttpPort(), + 'httpsPort' => $this->context->getHttpsPort(), + '_route' => $route, + ]; + } +} diff --git a/lib/RoadizCoreBundle/src/Routing/RedirectionMatcher.php b/lib/RoadizCoreBundle/src/Routing/RedirectionMatcher.php new file mode 100644 index 00000000..3579b0e1 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Routing/RedirectionMatcher.php @@ -0,0 +1,79 @@ +stopwatch = $stopwatch; + $this->logger = $logger; + $this->managerRegistry = $managerRegistry; + } + + /** + * {@inheritdoc} + */ + public function match($pathinfo): array + { + $this->stopwatch->start('findRedirection'); + $decodedUrl = rawurldecode($pathinfo); + + /* + * Try nodes routes + */ + if (null !== $redirection = $this->matchRedirection($decodedUrl)) { + $this->logger->debug('Matched redirection.', ['query' => $redirection->getQuery()]); + $this->stopwatch->stop('findRedirection'); + return [ + '_controller' => RedirectionController::class . '::redirectAction', + 'redirection' => $redirection, + '_route' => null, + ]; + } + $this->stopwatch->stop('findRedirection'); + + throw new ResourceNotFoundException(sprintf('%s did not match any Doctrine Redirection', $pathinfo)); + } + + /** + * @param string $decodedUrl + * @return Redirection|null + */ + protected function matchRedirection(string $decodedUrl): ?Redirection + { + return $this->managerRegistry->getRepository(Redirection::class)->findOneByQuery($decodedUrl); + } +} diff --git a/lib/RoadizCoreBundle/src/Routing/RedirectionPathResolver.php b/lib/RoadizCoreBundle/src/Routing/RedirectionPathResolver.php new file mode 100644 index 00000000..b684f7f4 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Routing/RedirectionPathResolver.php @@ -0,0 +1,37 @@ +managerRegistry = $managerRegistry; + } + + public function resolvePath( + string $path, + array $supportedFormatExtensions = ['html'], + bool $allowRootPaths = false, + bool $allowNonReachableNodes = true + ): ResourceInfo { + $redirection = $this->managerRegistry + ->getRepository(Redirection::class) + ->findOneByQuery($path); + + if (null === $redirection) { + throw new ResourceNotFoundException(); + } + + return (new ResourceInfo()) + ->setResource($redirection); + } +} diff --git a/lib/RoadizCoreBundle/src/Routing/RedirectionRouter.php b/lib/RoadizCoreBundle/src/Routing/RedirectionRouter.php new file mode 100644 index 00000000..8df68ff7 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Routing/RedirectionRouter.php @@ -0,0 +1,82 @@ +stopwatch = $stopwatch; + $this->managerRegistry = $managerRegistry; + $this->matcher = $matcher; + } + + /** + * {@inheritdoc} + */ + public function getRouteCollection(): RouteCollection + { + return new RouteCollection(); + } + + /** + * {@inheritdoc} + */ + public function generate(string $name, array $parameters = [], int $referenceType = self::ABSOLUTE_PATH): string + { + return ''; + } + + /** + * No generator for a node router. + */ + public function getGenerator(): UrlGeneratorInterface + { + throw new \BadMethodCallException(get_class($this) . ' does not support path generation.'); + } + + public function supports($name): bool + { + return false; + } + + public function getRouteDebugMessage($name, array $parameters = []): string + { + return 'RedirectionRouter does not support path generation.'; + } +} diff --git a/lib/RoadizCoreBundle/src/Routing/ResourceInfo.php b/lib/RoadizCoreBundle/src/Routing/ResourceInfo.php new file mode 100644 index 00000000..d25db2f5 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Routing/ResourceInfo.php @@ -0,0 +1,88 @@ +resource; + } + + /** + * @param PersistableInterface|null $resource + * @return ResourceInfo + */ + public function setResource(?PersistableInterface $resource): ResourceInfo + { + $this->resource = $resource; + return $this; + } + + /** + * @return TranslationInterface|null + */ + public function getTranslation(): ?TranslationInterface + { + return $this->translation; + } + + /** + * @param TranslationInterface|null $translation + * @return ResourceInfo + */ + public function setTranslation(?TranslationInterface $translation): ResourceInfo + { + $this->translation = $translation; + return $this; + } + + /** + * @return string|null + */ + public function getFormat(): ?string + { + return $this->format; + } + + /** + * @param string|null $format + * @return ResourceInfo + */ + public function setFormat(?string $format): ResourceInfo + { + $this->format = $format; + return $this; + } + + /** + * @return string|null + */ + public function getLocale(): ?string + { + return $this->locale; + } + + /** + * @param string|null $locale + * @return ResourceInfo + */ + public function setLocale(?string $locale): ResourceInfo + { + $this->locale = $locale; + return $this; + } +} diff --git a/lib/RoadizCoreBundle/src/Routing/RouteHandler.php b/lib/RoadizCoreBundle/src/Routing/RouteHandler.php new file mode 100644 index 00000000..01146723 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Routing/RouteHandler.php @@ -0,0 +1,22 @@ +endsWith('Locale')) { + $path = StringHandler::replaceLast("Locale", "", $path); + } + return $path; + } +} diff --git a/lib/RoadizCoreBundle/src/Routing/StaticRouter.php b/lib/RoadizCoreBundle/src/Routing/StaticRouter.php new file mode 100644 index 00000000..4f9b917c --- /dev/null +++ b/lib/RoadizCoreBundle/src/Routing/StaticRouter.php @@ -0,0 +1,52 @@ +routeCollection = $routeCollection; + } + + /** + * @return RouteCollection + */ + public function getRouteCollection(): RouteCollection + { + if (null === $this->collection) { + $this->routeCollection->parseResources(); + $this->collection = $this->routeCollection; + } + return $this->collection; + } +} diff --git a/lib/RoadizCoreBundle/src/SearchEngine/AbstractSearchHandler.php b/lib/RoadizCoreBundle/src/SearchEngine/AbstractSearchHandler.php new file mode 100644 index 00000000..2265673a --- /dev/null +++ b/lib/RoadizCoreBundle/src/SearchEngine/AbstractSearchHandler.php @@ -0,0 +1,381 @@ +clientRegistry = $clientRegistry; + $this->em = $em; + $this->logger = $searchEngineLogger; + } + + public function getSolr(): Client + { + $solr = $this->clientRegistry->getClient(); + if (null === $solr) { + throw new SolrServerNotAvailableException(); + } + return $solr; + } + + /** + * Search on Solr with pre-filled argument for highlighting + * + * * $q is the search criteria. + * * $args is an array with solr query argument. + * The common argument can be found [here](https://cwiki.apache.org/confluence/display/solr/Common+Query+Parameters) + * and for highlighting argument is [here](https://cwiki.apache.org/confluence/display/solr/Standard+Highlighter). + * + * @param string $q + * @param array $args + * @param int $rows + * @param bool $searchTags Search in tags/folders too, even if a node don’t match + * @param int $proximity Proximity matching: Lucene supports finding words are a within a specific distance away. + * @param int $page + * + * @return SearchResultsInterface Return a SearchResultsInterface iterable object. + */ + public function searchWithHighlight( + string $q, + array $args = [], + int $rows = 20, + bool $searchTags = false, + int $proximity = 1, + int $page = 1 + ): SearchResultsInterface { + $args = $this->argFqProcess($args); + $args["fq"][] = "document_type_s:" . $this->getDocumentType(); + $args = array_merge($this->getHighlightingOptions($args), $args); + $response = $this->nativeSearch($q, $args, $rows, $searchTags, $proximity, $page); + return new SolrSearchResults(null !== $response ? $response : [], $this->em); + } + + /** + * @param array $args + * @return array + */ + abstract protected function argFqProcess(array &$args): array; + + /** + * @return string + */ + abstract protected function getDocumentType(): string; + + /** + * @param array $args + * @return array + */ + protected function getHighlightingOptions(array &$args = []): array + { + $tmp = []; + $tmp["hl"] = true; + $tmp["hl.fl"] = $this->getCollectionField($args); + $tmp["hl.fragsize"] = $this->getHighlightingFragmentSize(); + $tmp["hl.simple.pre"] = ''; + $tmp["hl.simple.post"] = ''; + + return $tmp; + } + + /** + * @param array $args + * + * @return string + */ + protected function getCollectionField(array &$args): string + { + /* + * Use collection_txt_LOCALE when search + * is filtered by translation. + */ + if (isset($args['locale']) && is_string($args['locale'])) { + return 'collection_txt_' . \Locale::getPrimaryLanguage($args['locale']); + } + if (isset($args['translation']) && $args['translation'] instanceof Translation) { + return 'collection_txt_' . \Locale::getPrimaryLanguage($args['translation']->getLocale()); + } + return 'collection_txt'; + } + + /** + * @return int + */ + public function getHighlightingFragmentSize(): int + { + return $this->highlightingFragmentSize; + } + + /** + * @param int $highlightingFragmentSize + * + * @return AbstractSearchHandler + */ + public function setHighlightingFragmentSize(int $highlightingFragmentSize): AbstractSearchHandler + { + $this->highlightingFragmentSize = $highlightingFragmentSize; + + return $this; + } + + /** + * @param string $q + * @param array $args + * @param int $rows + * @param bool $searchTags + * @param int $proximity Proximity matching: Lucene supports finding words are a within a specific distance away. + * @param int $page + * + * @return array|null + */ + abstract protected function nativeSearch( + string $q, + array $args = [], + int $rows = 20, + bool $searchTags = false, + int $proximity = 1, + int $page = 1 + ): ?array; + + /** + * ## Search on Solr. + * + * * $q is the search criteria. + * * $args is a array with solr query argument. + * The common argument can be found [here](https://cwiki.apache.org/confluence/display/solr/Common+Query+Parameters) + * and for highlighting argument is [here](https://cwiki.apache.org/confluence/display/solr/Standard+Highlighter). + * + * You can use shortcuts in $args array to filter: + * + * ### For node-sources: + * + * * status (int) + * * visible (boolean) + * * nodeType (RZ\Roadiz\CoreBundle\Entity\NodeType or string or array) + * * tags (RZ\Roadiz\CoreBundle\Entity\Tag or array of Tag) + * * translation (RZ\Roadiz\CoreBundle\Entity\Translation) + * + * For other filters, use $args['fq'][] array, eg. + * + * $args["fq"][] = "title:My title"; + * + * this explicitly filter by title. + * + * + * @param string $q + * @param array $args + * @param int $rows Results per page + * @param boolean $searchTags Search in tags/folders too, even if a node don’t match + * @param int $proximity Proximity matching: Lucene supports finding words are a within a specific distance away. Default 10000000 + * @param int $page Retrieve a specific page + * + * @return SearchResultsInterface Return an array of doctrine Entities (Document, NodesSources) + */ + public function search( + string $q, + array $args = [], + int $rows = 20, + bool $searchTags = false, + int $proximity = 1, + int $page = 1 + ): SearchResultsInterface { + $args = $this->argFqProcess($args); + $args["fq"][] = "document_type_s:" . $this->getDocumentType(); + $tmp = []; + $args = array_merge($tmp, $args); + + $response = $this->nativeSearch($q, $args, $rows, $searchTags, $proximity, $page); + return new SolrSearchResults(null !== $response ? $response : [], $this->em); + } + + /** + * @param string $input + * @return string + */ + public function escapeQuery(string $input): string + { + $qHelper = new Helper(); + $input = $qHelper->filterControlCharacters($input); + $input = $qHelper->escapeTerm($input); + // Solarium does not escape Lucene reserved words + // https://stackoverflow.com/questions/10337908/how-to-properly-escape-or-and-and-in-lucene-query + $input = preg_replace("#\\b(AND|OR|NOT)\\b#", "\\\\\\\\$1", $input); + + return $input; + } + + /** + * Default Solr query builder. + * + * Extends this method to customize your Solr queries. Eg. to boost custom fields. + * + * @param string $q + * @param array $args + * @param bool $searchTags + * @param int $proximity + * @return string + */ + protected function buildQuery(string $q, array &$args, bool $searchTags = false, int $proximity = 1): string + { + $q = trim($q); + $singleWord = $this->isQuerySingleWord($q); + $titleField = $this->getTitleField($args); + $collectionField = $this->getCollectionField($args); + $tagsField = $this->getTagsField($args); + + /** + * Generate a fuzzy query by appending proximity to each word + * @see https://lucene.apache.org/solr/guide/6_6/the-standard-query-parser.html#TheStandardQueryParser-FuzzySearches + */ + $words = preg_split('#[\s,]+#', $q, -1, PREG_SPLIT_NO_EMPTY); + $fuzzyiedQuery = implode(' ', array_map(function (string $word) use ($proximity) { + /* + * Do not fuzz short words: Solr crashes + * Proximity is set to 1 by default for single-words + */ + if (strlen($word) > 3) { + return $this->escapeQuery($word) . '~' . $proximity; + } + return $this->escapeQuery($word); + }, $words)); + /* + * Only escape exact query + */ + $exactQuery = $this->escapeQuery($q); + if (!$singleWord) { + /* + * adds quotes if multi word exact query + */ + $exactQuery = '"' . $exactQuery . '"'; + } + + /* + * Search in node-sources tags name… + */ + if ($searchTags) { + // Need to use Fuzzy search AND Exact search + return sprintf( + '(' . $titleField . ':%s)^10 (' . $titleField . ':%s) (' . $collectionField . ':%s)^2 (' . $collectionField . ':%s) (' . $tagsField . ':%s) (' . $tagsField . ':%s)', + $exactQuery, + $fuzzyiedQuery, + $exactQuery, + $fuzzyiedQuery, + $exactQuery, + $fuzzyiedQuery + ); + } else { + return sprintf( + '(' . $titleField . ':%s)^10 (' . $titleField . ':%s) (' . $collectionField . ':%s)^2 (' . $collectionField . ':%s)', + $exactQuery, + $fuzzyiedQuery, + $exactQuery, + $fuzzyiedQuery + ); + } + } + + /** + * @param string $q + * + * @return bool + */ + protected function isQuerySingleWord(string $q): bool + { + return preg_match('#[\s\-\'\"\–\—\’\”\‘\“\/\+\.\,]#', $q) !== 1; + } + + /** + * @param array $args + * + * @return string + */ + protected function getTitleField(array &$args): string + { + /* + * Use title_txt_LOCALE when search + * is filtered by translation. + */ + if (isset($args['locale']) && is_string($args['locale'])) { + return 'title_txt_' . \Locale::getPrimaryLanguage($args['locale']); + } + if (isset($args['translation']) && $args['translation'] instanceof Translation) { + return 'title_txt_' . \Locale::getPrimaryLanguage($args['translation']->getLocale()); + } + return 'title'; + } + + /** + * @param array $args + * + * @return string + */ + protected function getTagsField(array &$args): string + { + /* + * Use tags_txt_LOCALE when search + * is filtered by translation. + */ + if (isset($args['locale']) && is_string($args['locale'])) { + return 'tags_txt_' . \Locale::getPrimaryLanguage($args['locale']); + } + if (isset($args['translation']) && $args['translation'] instanceof Translation) { + return 'tags_txt_' . \Locale::getPrimaryLanguage($args['translation']->getLocale()); + } + return 'tags_txt'; + } + + /** + * Create Solr Select query. Override it to add DisMax fields and rules. + * + * @param array $args + * @param int $rows + * @param int $page + * @return Query + */ + protected function createSolrQuery(array &$args = [], int $rows = 20, int $page = 1): Query + { + $query = $this->getSolr()->createSelect(); + + foreach ($args as $key => $value) { + if (is_array($value)) { + foreach ($value as $k => $v) { + $query->addFilterQuery([ + "key" => "fq" . $k, + "query" => $v, + ]); + } + } elseif (is_scalar($value)) { + $query->addParam($key, $value); + } + } + /** + * Add start if not first page. + */ + if ($page > 1) { + $query->setStart(($page - 1) * $rows); + } + $query->addSort('score', $query::SORT_DESC); + $query->setRows($rows); + + return $query; + } +} diff --git a/lib/RoadizCoreBundle/src/SearchEngine/AbstractSolarium.php b/lib/RoadizCoreBundle/src/SearchEngine/AbstractSolarium.php new file mode 100644 index 00000000..5b1e50b4 --- /dev/null +++ b/lib/RoadizCoreBundle/src/SearchEngine/AbstractSolarium.php @@ -0,0 +1,313 @@ +logger = $searchEngineLogger; + $this->markdown = $markdown; + $this->clientRegistry = $clientRegistry; + } + + public function getSolr(): Client + { + $solr = $this->clientRegistry->getClient(); + if (null === $solr) { + throw new SolrServerNotAvailableException(); + } + return $solr; + } + + /** + * Index current nodeSource and commit after. + * + * Use this method only when you need to index single NodeSources. + * + * @return ResultInterface|null + * @throws \Exception + */ + public function indexAndCommit(): ?ResultInterface + { + $update = $this->getSolr()->createUpdate(); + $this->createEmptyDocument($update); + + if (true === $this->index()) { + // add the documents and a commit command to the update query + $update->addDocument($this->getDocument()); + $update->addCommit(); + + return $this->getSolr()->update($update); + } + + return null; + } + + /** + * Update current nodeSource document and commit after. + * + * Use this method **only** when you need to re-index a single NodeSources. + * + * @return ResultInterface|null + * @throws \Exception + */ + public function updateAndCommit(): ?ResultInterface + { + $update = $this->getSolr()->createUpdate(); + $this->update($update); + $update->addCommit(true, true, false); + + $this->logger->debug('[Solr] Document updated.'); + return $this->getSolr()->update($update); + } + + /** + * Update current nodeSource document with existing update. + * + * Use this method only when you need to re-index bulk NodeSources. + * + * @param Query $update + * + * @throws \Exception + */ + public function update(Query $update): void + { + $this->clean($update); + $this->createEmptyDocument($update); + $this->index(); + // add the document to the update query + $update->addDocument($this->document); + } + + /** + * Remove current document from SearchEngine index. + * + * @param Query $update + * @return bool + * @throws \RuntimeException If no document is available. + */ + public function remove(Query $update): bool + { + if (null !== $this->document && isset($this->document->id)) { + $update->addDeleteById($this->document->id); + + return true; + } else { + return false; + } + } + + /** + * Remove current Solr document and commit after. + * + * Use this method only when you need to remove a single NodeSources. + */ + public function removeAndCommit(): void + { + $update = $this->getSolr()->createUpdate(); + + if (true === $this->remove($update)) { + $update->addCommit(true, true, false); + $this->getSolr()->update($update); + } + } + /** + * Remove any document linked to current node-source and commit after. + * + * Use this method only when you need to remove a single NodeSources. + */ + public function cleanAndCommit(): void + { + $update = $this->getSolr()->createUpdate(); + + if (true === $this->clean($update)) { + $update->addCommit(true, true, false); + $this->getSolr()->update($update); + } + } + + /** + * Index current document with entity data. + * + * @return bool + * @throws \Exception + */ + public function index(): bool + { + if ($this->document instanceof Document) { + $this->document->setKey('id', uniqid('', true)); + + try { + foreach ($this->getFieldsAssoc() as $key => $value) { + if (!\is_array($value) || \count($value) > 0) { + $this->document->setField($key, $value); + } + } + return true; + } catch (\RuntimeException $e) { + return false; + } + } + throw new \RuntimeException("No Solr item available for current entity", 1); + } + + /** + * @return DocumentInterface|null + */ + public function getDocument(): ?DocumentInterface + { + return $this->document; + } + + /** + * @param DocumentInterface $document + * @return $this + * @deprecated Use createEmptyDocument instead of set an empty Solr document. + */ + public function setDocument(DocumentInterface $document): self + { + $this->document = $document; + return $this; + } + + /** + * @param Query $update + * @return $this + */ + public function createEmptyDocument(Query $update): self + { + $this->document = $update->createDocument(); + return $this; + } + + abstract public function clean(Query $update): bool; + + + /** + * @return int|string + */ + abstract public function getDocumentId(): int|string; + + /** + * Get document from Solr index. + * + * @return bool *FALSE* if no document found linked to current node-source. + */ + public function getDocumentFromIndex(): bool + { + $query = $this->getSolr()->createSelect(); + $query->setQuery(static::IDENTIFIER_KEY . ':' . $this->getDocumentId()); + $query->createFilterQuery('type')->setQuery(static::TYPE_DISCRIMINATOR . ':' . static::DOCUMENT_TYPE); + + // this executes the query and returns the result + $resultset = $this->getSolr()->select($query); + + if (0 === $resultset->getNumFound()) { + return false; + } else { + foreach ($resultset as $document) { + $this->document = $document; + return true; + } + } + return false; + } + + /** + * Get a key/value array representation of current indexed object. + * + * @return array + * @throws \Exception + */ + abstract protected function getFieldsAssoc(): array; + + /** + * @param string|null $content + * @param bool $stripMarkdown + * + * @return string|null + */ + public function cleanTextContent(?string $content, bool $stripMarkdown = true): ?string + { + if (!is_string($content)) { + return null; + } + /* + * Strip Markdown syntax + */ + if (true === $stripMarkdown) { + $content = $this->markdown->textExtra($content); + // replace BR with space to avoid merged words. + $content = str_replace(['
', '
', '
'], ' ', $content); + $content = strip_tags($content); + } + /* + * Remove ctrl characters + */ + $content = preg_replace("[:cntrl:]", "", $content); + $content = preg_replace('/[\x00-\x1F]/', '', $content); + return $content; + } +} diff --git a/lib/RoadizCoreBundle/src/SearchEngine/ClientRegistry.php b/lib/RoadizCoreBundle/src/SearchEngine/ClientRegistry.php new file mode 100644 index 00000000..e15699dc --- /dev/null +++ b/lib/RoadizCoreBundle/src/SearchEngine/ClientRegistry.php @@ -0,0 +1,43 @@ +container = $container; + } + + public function getClient(): ?Client + { + return $this->container->get( + 'roadiz_core.solr.client', + ContainerInterface::NULL_ON_INVALID_REFERENCE + ); + } + + public function isClientReady(?Client $client): bool + { + if (null === $client) { + return false; + } + $ping = $client->createPing(); + try { + $client->ping($ping); + return true; + } catch (\Exception $e) { + return false; + } + } +} diff --git a/lib/RoadizCoreBundle/src/SearchEngine/DocumentSearchHandler.php b/lib/RoadizCoreBundle/src/SearchEngine/DocumentSearchHandler.php new file mode 100644 index 00000000..a0b9c1eb --- /dev/null +++ b/lib/RoadizCoreBundle/src/SearchEngine/DocumentSearchHandler.php @@ -0,0 +1,137 @@ +createSolrQuery($args, $rows, $page); + $queryTxt = $this->buildQuery($q, $args, $searchTags, $proximity); + $query->setQuery($queryTxt); + + /* + * Only need these fields as Doctrine + * will do the rest. + */ + $query->setFields([ + 'id', + 'sort', + 'document_type_s', + SolariumDocumentTranslation::IDENTIFIER_KEY, + 'filename_s', + 'locale_s', + ]); + + $this->logger->debug('[Solr] Request document search…', [ + 'query' => $queryTxt, + 'fq' => $args["fq"] ?? [], + 'params' => $query->getParams(), + ]); + + $solrRequest = $this->getSolr()->execute($query); + return $solrRequest->getData(); + } else { + return null; + } + } + + /** + * @param array $args + * @return array + */ + protected function argFqProcess(array &$args): array + { + if (!isset($args["fq"])) { + $args["fq"] = []; + } + + /* + * `all_tags_txt` can store all folders, even technical ones, this fields should not user-searchable. + */ + if (!empty($args['folders'])) { + if ($args['folders'] instanceof Folder) { + $args["fq"][] = sprintf('all_tags_txt:"%s"', $args['folders']->getFolderName()); + } elseif (is_array($args['folders'])) { + foreach ($args['folders'] as $folder) { + if ($folder instanceof Folder) { + $args["fq"][] = sprintf('all_tags_txt:"%s"', $folder->getFolderName()); + } + } + } + unset($args['folders']); + } + + if (isset($args['mimeType'])) { + $tmp = "mime_type_s:"; + if (!is_array($args['mimeType'])) { + $tmp .= (string) $args['mimeType']; + } else { + $value = implode(' AND ', $args['mimeType']); + $tmp .= '(' . $value . ')'; + } + unset($args['mimeType']); + $args["fq"][] = $tmp; + } + + /* + * Filter by translation or locale + */ + if (isset($args['translation']) && $args['translation'] instanceof Translation) { + $args["fq"][] = "locale_s:" . $args['translation']->getLocale(); + } + if (isset($args['locale']) && is_string($args['locale'])) { + $args["fq"][] = "locale_s:" . $args['locale']; + } + + /* + * Filter by filename + */ + if (isset($args['filename'])) { + $args["fq"][] = sprintf('filename_s:"%s"', trim($args['filename'])); + } + + /* + * Filter out non-valid copyright documents + */ + if (isset($args['copyrightValid'])) { + $args["fq"][] = '(copyright_valid_since_dt:[* TO NOW] AND copyright_valid_until_dt:[NOW TO *])'; + unset($args['copyrightValid']); + } + + return $args; + } + + /** + * @return string + */ + protected function getDocumentType(): string + { + return 'DocumentTranslation'; + } +} diff --git a/lib/RoadizCoreBundle/src/SearchEngine/GlobalNodeSourceSearchHandler.php b/lib/RoadizCoreBundle/src/SearchEngine/GlobalNodeSourceSearchHandler.php new file mode 100644 index 00000000..73da71f3 --- /dev/null +++ b/lib/RoadizCoreBundle/src/SearchEngine/GlobalNodeSourceSearchHandler.php @@ -0,0 +1,107 @@ +em = $em; + } + + /** + * @return EntityRepository + */ + protected function getRepository(): EntityRepository + { + return $this->em->getRepository(NodesSources::class); + } + + /** + * @param bool $displayNonPublishedNodes + * + * @return $this + */ + public function setDisplayNonPublishedNodes(bool $displayNonPublishedNodes): self + { + $this->getRepository()->setDisplayingNotPublishedNodes($displayNonPublishedNodes); + return $this; + } + + /** + * @param string $searchTerm + * @param int $resultCount + * @param Translation|null $translation + * @return NodesSources[] + */ + public function getNodeSourcesBySearchTerm( + string $searchTerm, + int $resultCount, + ?Translation $translation = null + ): array { + $safeSearchTerms = strip_tags($searchTerm); + + /* + * First try with Solr + */ + /** @var array $nodesSources */ + $nodesSources = $this->getRepository()->findBySearchQuery( + $safeSearchTerms, + $resultCount + ); + + /* + * Second try with sources fields + */ + if (count($nodesSources) === 0) { + $nodesSources = $this->getRepository()->searchBy( + $safeSearchTerms, + [], + [], + $resultCount + ); + + if (count($nodesSources) === 0) { + /* + * Then try with node name. + */ + $qb = $this->getRepository()->createQueryBuilder('ns'); + + $qb->select('ns, n') + ->innerJoin('ns.node', 'n') + ->andWhere($qb->expr()->orX( + $qb->expr()->like('n.nodeName', ':nodeName'), + $qb->expr()->like('ns.title', ':nodeName') + )) + ->setMaxResults($resultCount) + ->setParameter('nodeName', '%' . $safeSearchTerms . '%'); + + if (null !== $translation) { + $qb->andWhere($qb->expr()->eq('ns.translation', ':translation')) + ->setParameter('translation', $translation); + } + try { + return $qb->getQuery()->getResult(); + } catch (NoResultException $e) { + return []; + } + } + } + + return $nodesSources; + } +} diff --git a/lib/RoadizCoreBundle/src/SearchEngine/Indexer/AbstractIndexer.php b/lib/RoadizCoreBundle/src/SearchEngine/Indexer/AbstractIndexer.php new file mode 100644 index 00000000..ee723f7f --- /dev/null +++ b/lib/RoadizCoreBundle/src/SearchEngine/Indexer/AbstractIndexer.php @@ -0,0 +1,93 @@ +solariumFactory = $solariumFactory; + $this->clientRegistry = $clientRegistry; + $this->logger = $searchEngineLogger; + $this->managerRegistry = $managerRegistry; + } + + /** + * @return Client + */ + public function getSolr(): Client + { + $solr = $this->clientRegistry->getClient(); + if (null === $solr) { + throw new SolrServerNotAvailableException(); + } + return $solr; + } + + /** + * @param SymfonyStyle|null $io + * @return AbstractIndexer + */ + public function setIo(?SymfonyStyle $io): self + { + $this->io = $io; + return $this; + } + + /** + * Empty Solr index. + * + * @param string|null $documentType + */ + public function emptySolr(?string $documentType = null): void + { + $update = $this->getSolr()->createUpdate(); + if (null !== $documentType) { + $update->addDeleteQuery(sprintf('document_type_s:"%s"', trim($documentType))); + } else { + // Delete ALL index + $update->addDeleteQuery('*:*'); + } + $update->addCommit(false, true, true); + $this->getSolr()->update($update); + } + + /** + * Send an optimize and commit update query to Solr. + */ + public function optimizeSolr(): void + { + $optimizeUpdate = $this->getSolr()->createUpdate(); + $optimizeUpdate->addOptimize(true, true); + $this->getSolr()->update($optimizeUpdate); + + $this->commitSolr(); + } + + public function commitSolr(): void + { + $finalCommitUpdate = $this->getSolr()->createUpdate(); + $finalCommitUpdate->addCommit(true, true, false); + $this->getSolr()->update($finalCommitUpdate); + } +} diff --git a/lib/RoadizCoreBundle/src/SearchEngine/Indexer/BatchIndexer.php b/lib/RoadizCoreBundle/src/SearchEngine/Indexer/BatchIndexer.php new file mode 100644 index 00000000..0c9e5439 --- /dev/null +++ b/lib/RoadizCoreBundle/src/SearchEngine/Indexer/BatchIndexer.php @@ -0,0 +1,10 @@ +managerRegistry->getRepository(Document::class)->find($id); + if (null !== $document) { + try { + foreach ($document->getDocumentTranslations() as $documentTranslation) { + $solarium = $this->solariumFactory->createWithDocumentTranslation($documentTranslation); + $solarium->getDocumentFromIndex(); + $solarium->updateAndCommit(); + } + } catch (HttpException $exception) { + $this->logger->error($exception->getMessage()); + } + } + } + + public function delete(mixed $id): void + { + $document = $this->managerRegistry->getRepository(Document::class)->find($id); + if (null !== $document) { + try { + foreach ($document->getDocumentTranslations() as $documentTranslation) { + $solarium = $this->solariumFactory->createWithDocumentTranslation($documentTranslation); + $solarium->getDocumentFromIndex(); + $solarium->removeAndCommit(); + } + } catch (HttpException $exception) { + $this->logger->error($exception->getMessage()); + } + } + } + + public function reindexAll(): void + { + $update = $this->getSolr()->createUpdate(); + /* + * Use buffered insertion + */ + /** @var BufferedAdd $buffer */ + $buffer = $this->getSolr()->getPlugin('bufferedadd'); + $buffer->setBufferSize(100); + + $countQuery = $this->managerRegistry + ->getRepository(Document::class) + ->createQueryBuilder('d') + ->select('count(d)') + ->getQuery(); + $q = $this->managerRegistry->getRepository(Document::class) + ->createQueryBuilder('d') + ->getQuery(); + + if (null !== $this->io) { + $this->io->progressStart((int) $countQuery->getSingleScalarResult()); + } + + foreach ($q->toIterable() as $row) { + $solarium = $this->solariumFactory->createWithDocument($row); + $solarium->createEmptyDocument($update); + $solarium->index(); + foreach ($solarium->getDocuments() as $document) { + $buffer->addDocument($document); + } + if (null !== $this->io) { + $this->io->progressAdvance(); + } + // detach from Doctrine, so that it can be Garbage-Collected immediately + $this->managerRegistry->getManager()->detach($row); + } + + $buffer->flush(); + + // optimize the index + $this->optimizeSolr(); + if (null !== $this->io) { + $this->io->progressFinish(); + } + } +} diff --git a/lib/RoadizCoreBundle/src/SearchEngine/Indexer/FolderIndexer.php b/lib/RoadizCoreBundle/src/SearchEngine/Indexer/FolderIndexer.php new file mode 100644 index 00000000..c476a025 --- /dev/null +++ b/lib/RoadizCoreBundle/src/SearchEngine/Indexer/FolderIndexer.php @@ -0,0 +1,52 @@ +managerRegistry->getRepository(Folder::class)->find($id); + if (null === $folder) { + return; + } + $update = $this->getSolr()->createUpdate(); + $documents = $folder->getDocuments(); + + foreach ($documents as $document) { + if ($document instanceof Document) { + foreach ($document->getDocumentTranslations() as $documentTranslation) { + $solarium = $this->solariumFactory->createWithDocumentTranslation($documentTranslation); + $solarium->getDocumentFromIndex(); + $solarium->update($update); + } + } + } + $this->getSolr()->update($update); + + // then optimize + $optimizeUpdate = $this->getSolr()->createUpdate(); + $optimizeUpdate->addOptimize(true, true, 5); + $this->getSolr()->update($optimizeUpdate); + // and commit + $finalCommitUpdate = $this->getSolr()->createUpdate(); + $finalCommitUpdate->addCommit(true, true, false); + $this->getSolr()->update($finalCommitUpdate); + } catch (HttpException $exception) { + $this->logger->error($exception->getMessage()); + } + } + + public function delete(mixed $id): void + { + // Just reindex all linked documents to get rid of folder + $this->index($id); + } +} diff --git a/lib/RoadizCoreBundle/src/SearchEngine/Indexer/Indexer.php b/lib/RoadizCoreBundle/src/SearchEngine/Indexer/Indexer.php new file mode 100644 index 00000000..2030841b --- /dev/null +++ b/lib/RoadizCoreBundle/src/SearchEngine/Indexer/Indexer.php @@ -0,0 +1,14 @@ +serviceLocator = $serviceLocator; + } + + /** + * @param class-string $classname + * @return Indexer + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ + public function getIndexerFor(string $classname): Indexer + { + return match ($classname) { + Node::class => $this->serviceLocator->get(NodeIndexer::class), + NodesSources::class => $this->serviceLocator->get(NodesSourcesIndexer::class), + Document::class => $this->serviceLocator->get(DocumentIndexer::class), + Tag::class => $this->serviceLocator->get(TagIndexer::class), + Folder::class => $this->serviceLocator->get(FolderIndexer::class), + default => throw new LogicException(sprintf('No indexer found for "%s"', $classname)), + }; + } +} diff --git a/lib/RoadizCoreBundle/src/SearchEngine/Indexer/IndexerFactoryInterface.php b/lib/RoadizCoreBundle/src/SearchEngine/Indexer/IndexerFactoryInterface.php new file mode 100644 index 00000000..03952dff --- /dev/null +++ b/lib/RoadizCoreBundle/src/SearchEngine/Indexer/IndexerFactoryInterface.php @@ -0,0 +1,14 @@ +managerRegistry->getRepository(Node::class)->find($id); + if (null !== $node) { + $update = $this->getSolr()->createUpdate(); + /** @var NodesSources $nodeSource */ + foreach ($node->getNodeSources() as $nodeSource) { + $this->indexNodeSource($nodeSource, $update); + } + $update->addCommit(true, true, false); + $this->getSolr()->update($update); + } + } + + public function delete(mixed $id): void + { + $node = $this->managerRegistry->getRepository(Node::class)->find($id); + if (null !== $node) { + foreach ($node->getNodeSources() as $nodeSource) { + $this->deleteNodeSource($nodeSource); + } + + // optimize the index + $this->commitSolr(); + } + } +} diff --git a/lib/RoadizCoreBundle/src/SearchEngine/Indexer/NodesSourcesIndexer.php b/lib/RoadizCoreBundle/src/SearchEngine/Indexer/NodesSourcesIndexer.php new file mode 100644 index 00000000..a7780845 --- /dev/null +++ b/lib/RoadizCoreBundle/src/SearchEngine/Indexer/NodesSourcesIndexer.php @@ -0,0 +1,144 @@ +getSolr()->createUpdate(); + $this->indexNodeSource( + $this->managerRegistry->getRepository(NodesSources::class)->find($id), + $update + ); + $update->addCommit(true, true, false); + $this->getSolr()->update($update); + } + + protected function indexNodeSource(?NodesSources $nodeSource, UpdateQuery $update): void + { + if (null !== $nodeSource) { + try { + $solrSource = $this->solariumFactory->createWithNodesSources($nodeSource); + $solrSource->getDocumentFromIndex(); + $solrSource->update($update); + } catch (HttpException $exception) { + $this->logger->error($exception->getMessage()); + } + } + } + + public function delete(mixed $id): void + { + $this->deleteNodeSource($this->managerRegistry->getRepository(NodesSources::class)->find($id)); + } + + protected function deleteNodeSource(?NodesSources $nodeSource): void + { + if (null !== $nodeSource) { + try { + $solrSource = $this->solariumFactory->createWithNodesSources($nodeSource); + $solrSource->getDocumentFromIndex(); + $solrSource->removeAndCommit(); + } catch (HttpException $exception) { + $this->logger->error($exception->getMessage()); + } + } + } + + /** + * Overridable + * + * @return QueryBuilder + */ + protected function getAllQueryBuilder(): QueryBuilder + { + return $this->managerRegistry + ->getRepository(NodesSources::class) + ->createQueryBuilder('ns') + ->innerJoin('ns.node', 'n'); + } + + /** + * Loop over every NodesSources to index them again. + * + * @param int $batchCount Split reindex span to several batches. + * @param int $batchNumber Execute reindex on a specific batch. + * @throws \Doctrine\ORM\NoResultException + * @throws \Doctrine\ORM\NonUniqueResultException + */ + public function reindexAll(int $batchCount = 1, int $batchNumber = 0): void + { + $update = $this->getSolr()->createUpdate(); + /* + * Use buffered insertion + */ + /** @var BufferedAdd $buffer */ + $buffer = $this->getSolr()->getPlugin('bufferedadd'); + $buffer->setBufferSize(100); + + $countQuery = $this->getAllQueryBuilder() + ->select('count(ns)') + ->getQuery(); + $count = (int) $countQuery->getSingleScalarResult(); + + $baseQb = $this->getAllQueryBuilder()->addSelect('n'); + if ($batchCount > 1) { + $limit = (int) ceil($count / $batchCount); + $offset = (int) $batchNumber * $limit; + if ($batchNumber === $batchCount - 1) { + $limit = $count - $offset; + $baseQb->setMaxResults($limit)->setFirstResult($offset); + if (null !== $this->io) { + $this->io->note(sprintf('Batch mode enabled (last): from %d to %d', $offset, ($offset + $limit) - 1)); + } + } else { + $baseQb->setMaxResults($limit)->setFirstResult($offset); + if (null !== $this->io) { + $this->io->note(sprintf('Batch mode enabled: from %d to %d', $offset, ($offset + $limit) - 1)); + } + } + $count = $limit; + } + + /* + * Must use Paginator to avoid missing items due to SQL pagination issues with offset and limit + */ + $paginator = new Paginator($baseQb->getQuery(), true); + + if (null !== $this->io) { + $this->io->progressStart($count); + } + + foreach ($paginator as $row) { + $solarium = $this->solariumFactory->createWithNodesSources($row); + $solarium->createEmptyDocument($update); + $solarium->index(); + $buffer->addDocument($solarium->getDocument()); + + if (null !== $this->io) { + $this->io->progressAdvance(); + } + // detach from Doctrine, so that it can be Garbage-Collected immediately + $this->managerRegistry->getManager()->detach($row); + } + + $buffer->flush(); + + // optimize the index + $this->optimizeSolr(); + + if (null !== $this->io) { + $this->io->progressFinish(); + } + } +} diff --git a/lib/RoadizCoreBundle/src/SearchEngine/Indexer/TagIndexer.php b/lib/RoadizCoreBundle/src/SearchEngine/Indexer/TagIndexer.php new file mode 100644 index 00000000..99734a14 --- /dev/null +++ b/lib/RoadizCoreBundle/src/SearchEngine/Indexer/TagIndexer.php @@ -0,0 +1,49 @@ +managerRegistry->getRepository(Tag::class)->find($id); + if (null === $tag) { + return; + } + $update = $this->getSolr()->createUpdate(); + $nodes = $tag->getNodes(); + + foreach ($nodes as $node) { + foreach ($node->getNodeSources() as $nodeSource) { + $solrSource = $this->solariumFactory->createWithNodesSources($nodeSource); + $solrSource->getDocumentFromIndex(); + $solrSource->update($update); + } + } + $this->getSolr()->update($update); + + // then optimize + $optimizeUpdate = $this->getSolr()->createUpdate(); + $optimizeUpdate->addOptimize(true, true, 5); + $this->getSolr()->update($optimizeUpdate); + // and commit + $finalCommitUpdate = $this->getSolr()->createUpdate(); + $finalCommitUpdate->addCommit(true, true, false); + $this->getSolr()->update($finalCommitUpdate); + } catch (HttpException $exception) { + $this->logger->error($exception->getMessage()); + } + } + + public function delete(mixed $id): void + { + // Just reindex all linked NS to get rid of tag + $this->index($id); + } +} diff --git a/lib/RoadizCoreBundle/src/SearchEngine/Message/AbstractSolrMessage.php b/lib/RoadizCoreBundle/src/SearchEngine/Message/AbstractSolrMessage.php new file mode 100644 index 00000000..16c215a0 --- /dev/null +++ b/lib/RoadizCoreBundle/src/SearchEngine/Message/AbstractSolrMessage.php @@ -0,0 +1,41 @@ +classname = $classname; + $this->identifier = $identifier; + } + + /** + * @return string + */ + public function getClassname(): string + { + return $this->classname; + } + + /** + * @return mixed + */ + public function getIdentifier(): mixed + { + return $this->identifier; + } +} diff --git a/lib/RoadizCoreBundle/src/SearchEngine/Message/Handler/SolrDeleteMessageHandler.php b/lib/RoadizCoreBundle/src/SearchEngine/Message/Handler/SolrDeleteMessageHandler.php new file mode 100644 index 00000000..bf208a6a --- /dev/null +++ b/lib/RoadizCoreBundle/src/SearchEngine/Message/Handler/SolrDeleteMessageHandler.php @@ -0,0 +1,36 @@ +logger = $searchEngineLogger; + $this->indexerFactory = $indexerFactory; + } + + public function __invoke(SolrDeleteMessage $message): void + { + try { + if (!empty($message->getIdentifier())) { + $this->indexerFactory->getIndexerFor($message->getClassname())->delete($message->getIdentifier()); + } + } catch (SolrServerNotAvailableException $exception) { + return; + } catch (\LogicException $exception) { + $this->logger->error($exception->getMessage()); + } + } +} diff --git a/lib/RoadizCoreBundle/src/SearchEngine/Message/Handler/SolrReindexMessageHandler.php b/lib/RoadizCoreBundle/src/SearchEngine/Message/Handler/SolrReindexMessageHandler.php new file mode 100644 index 00000000..15b092f7 --- /dev/null +++ b/lib/RoadizCoreBundle/src/SearchEngine/Message/Handler/SolrReindexMessageHandler.php @@ -0,0 +1,36 @@ +logger = $searchEngineLogger; + $this->indexerFactory = $indexerFactory; + } + + public function __invoke(SolrReindexMessage $message): void + { + try { + if (!empty($message->getIdentifier())) { + $this->indexerFactory->getIndexerFor($message->getClassname())->index($message->getIdentifier()); + } + } catch (SolrServerNotAvailableException $exception) { + return; + } catch (\LogicException $exception) { + $this->logger->error($exception->getMessage()); + } + } +} diff --git a/lib/RoadizCoreBundle/src/SearchEngine/Message/SolrDeleteMessage.php b/lib/RoadizCoreBundle/src/SearchEngine/Message/SolrDeleteMessage.php new file mode 100644 index 00000000..b0eda6f6 --- /dev/null +++ b/lib/RoadizCoreBundle/src/SearchEngine/Message/SolrDeleteMessage.php @@ -0,0 +1,9 @@ +createSolrQuery($args, $rows, $page); + $queryTxt = $this->buildQuery($q, $args, $searchTags, $proximity); + + if ($this->boostByPublicationDate) { + $boost = '{!boost b=recip(ms(NOW,published_at_dt),3.16e-11,1,1)}'; + $queryTxt = $boost . $queryTxt; + } + if ($this->boostByUpdateDate) { + $boost = '{!boost b=recip(ms(NOW,updated_at_dt),3.16e-11,1,1)}'; + $queryTxt = $boost . $queryTxt; + } + if ($this->boostByCreationDate) { + $boost = '{!boost b=recip(ms(NOW,created_at_dt),3.16e-11,1,1)}'; + $queryTxt = $boost . $queryTxt; + } + + $query->setQuery($queryTxt); + + /* + * Only need these fields as Doctrine + * will do the rest. + */ + $query->setFields([ + 'score', + 'id', + 'document_type_s', + SolariumNodeSource::IDENTIFIER_KEY, + 'node_name_s', + 'locale_s', + ]); + + $this->logger->debug('[Solr] Request node-sources search…', [ + 'query' => $queryTxt, + 'fq' => $args["fq"] ?? [], + 'params' => $query->getParams(), + ]); + + $solrRequest = $this->getSolr()->execute($query); + return $solrRequest->getData(); + } else { + return null; + } + } + + /** + * @param array $args + * @return array + */ + protected function argFqProcess(array &$args): array + { + if (!isset($args["fq"])) { + $args["fq"] = []; + } + + $visible = $args['visible'] ?? $args['node.visible'] ?? null; + if (isset($visible)) { + $tmp = "node_visible_b:" . (($visible) ? 'true' : 'false'); + unset($args['visible']); + unset($args['node.visible']); + $args["fq"][] = $tmp; + } + + /* + * filter by tag or tags + * `all_tags_txt` can store all tags, even technical ones, this fields should not user-searchable. + */ + if (!empty($args['tags'])) { + if ($args['tags'] instanceof Tag) { + $args["fq"][] = sprintf('all_tags_txt:"%s"', $args['tags']->getTagName()); + } elseif (is_array($args['tags'])) { + foreach ($args['tags'] as $tag) { + if ($tag instanceof Tag) { + $args["fq"][] = sprintf('all_tags_txt:"%s"', $tag->getTagName()); + } + } + } + unset($args['tags']); + } + + /* + * Filter by Node type + */ + $nodeType = $args['nodeType'] ?? $args['node.nodeType'] ?? null; + if (!empty($nodeType)) { + if (is_array($nodeType) || $nodeType instanceof Collection) { + $orQuery = []; + foreach ($nodeType as $singleNodeType) { + if ($singleNodeType instanceof NodeTypeInterface) { + $orQuery[] = $singleNodeType->getName(); + } elseif (is_string($singleNodeType)) { + $orQuery[] = $singleNodeType; + } + } + $args["fq"][] = "node_type_s:(" . implode(' OR ', $orQuery) . ')'; + } elseif ($nodeType instanceof NodeTypeInterface) { + $args["fq"][] = "node_type_s:" . $nodeType->getName(); + } else { + $args["fq"][] = "node_type_s:" . $nodeType; + } + unset($args['nodeType']); + unset($args['node.nodeType']); + } + + /* + * Filter by parent node + */ + $parent = $args['parent'] ?? $args['node.parent'] ?? null; + if (!empty($parent)) { + if ($parent instanceof Node) { + $args["fq"][] = "node_parent_i:" . $parent->getId(); + } elseif (is_string($parent)) { + $args["fq"][] = "node_parent_s:" . trim($parent); + } elseif (is_numeric($parent)) { + $args["fq"][] = "node_parent_i:" . (int) $parent; + } + unset($args['parent']); + unset($args['node.parent']); + } + + /* + * Handle publication date-time filtering + */ + if (isset($args['publishedAt'])) { + $tmp = "published_at_dt:"; + if (!is_array($args['publishedAt']) && $args['publishedAt'] instanceof \DateTime) { + $tmp .= $args['publishedAt']->setTimezone(new \DateTimeZone('UTC'))->format('Y-m-d\TH:i:s\Z'); + } elseif ( + isset($args['publishedAt'][0]) && + $args['publishedAt'][0] === "BETWEEN" && + isset($args['publishedAt'][1]) && + $args['publishedAt'][1] instanceof \DateTime && + isset($args['publishedAt'][2]) && + $args['publishedAt'][2] instanceof \DateTime + ) { + $tmp .= "[" . + $args['publishedAt'][1]->setTimezone(new \DateTimeZone('UTC'))->format('Y-m-d\TH:i:s\Z') . + " TO " . + $args['publishedAt'][2]->setTimezone(new \DateTimeZone('UTC'))->format('Y-m-d\TH:i:s\Z') . "]"; + } elseif ( + isset($args['publishedAt'][0]) && + $args['publishedAt'][0] === "<=" && + isset($args['publishedAt'][1]) && + $args['publishedAt'][1] instanceof \DateTime + ) { + $tmp .= "[* TO " . $args['publishedAt'][1]->setTimezone(new \DateTimeZone('UTC'))->format('Y-m-d\TH:i:s\Z') . "]"; + } elseif ( + isset($args['publishedAt'][0]) && + $args['publishedAt'][0] === ">=" && + isset($args['publishedAt'][1]) && + $args['publishedAt'][1] instanceof \DateTime + ) { + $tmp .= "[" . $args['publishedAt'][1]->setTimezone(new \DateTimeZone('UTC'))->format('Y-m-d\TH:i:s\Z') . " TO *]"; + } + unset($args['publishedAt']); + $args["fq"][] = $tmp; + } + + $status = $args['status'] ?? $args['node.status'] ?? null; + if (isset($status)) { + $tmp = "node_status_i:"; + if (!is_array($status)) { + $tmp .= (string) $status; + } elseif ($status[0] == "<=") { + $tmp .= "[* TO " . (string) $status[1] . "]"; + } elseif ($status[0] == ">=") { + $tmp .= "[" . (string) $status[1] . " TO *]"; + } + unset($args['status']); + unset($args['node.status']); + $args["fq"][] = $tmp; + } else { + $args["fq"][] = "node_status_i:" . (string) (Node::PUBLISHED); + } + + /* + * Filter by translation or locale + */ + if (isset($args['translation']) && $args['translation'] instanceof TranslationInterface) { + $args["fq"][] = "locale_s:" . $args['translation']->getLocale(); + } + if (isset($args['locale']) && is_string($args['locale'])) { + $args["fq"][] = "locale_s:" . $args['locale']; + } + + return $args; + } + + /** + * @return string + */ + protected function getDocumentType(): string + { + return 'NodesSources'; + } + + /** + * @return NodeSourceSearchHandler + */ + public function boostByPublicationDate(): NodeSourceSearchHandler + { + $this->boostByPublicationDate = true; + $this->boostByUpdateDate = false; + $this->boostByCreationDate = false; + + return $this; + } + + /** + * @return NodeSourceSearchHandler + */ + public function boostByUpdateDate(): NodeSourceSearchHandler + { + $this->boostByPublicationDate = false; + $this->boostByUpdateDate = true; + $this->boostByCreationDate = false; + + return $this; + } + + /** + * @return NodeSourceSearchHandler + */ + public function boostByCreationDate(): NodeSourceSearchHandler + { + $this->boostByPublicationDate = false; + $this->boostByUpdateDate = false; + $this->boostByCreationDate = true; + + return $this; + } +} diff --git a/lib/RoadizCoreBundle/src/SearchEngine/NodeSourceSearchHandlerInterface.php b/lib/RoadizCoreBundle/src/SearchEngine/NodeSourceSearchHandlerInterface.php new file mode 100644 index 00000000..e14e4faa --- /dev/null +++ b/lib/RoadizCoreBundle/src/SearchEngine/NodeSourceSearchHandlerInterface.php @@ -0,0 +1,12 @@ +documentTranslationItems = []; + + foreach ($rzDocument->getDocumentTranslations() as $documentTranslation) { + $this->documentTranslationItems[] = $solariumFactory->createWithDocumentTranslation($documentTranslation); + } + } + + /** + * @deprecated + */ + public function getDocument(): ?DocumentInterface + { + throw new \RuntimeException('Method getDocument cannot be called for SolariumDocument.'); + } + + /** + * @return array<\Solarium\QueryType\Update\Query\Document|DocumentInterface> Each document translation Solr document + */ + public function getDocuments() + { + $documents = []; + /** @var SolariumDocumentTranslation $documentTranslationItem */ + foreach ($this->documentTranslationItems as $documentTranslationItem) { + $documents[] = $documentTranslationItem->getDocument(); + } + + return array_filter($documents); + } + + public function getDocumentId(): string|int + { + throw new \RuntimeException('SolariumDocument should not provide any ID'); + } + + /** + * Get document from Solr index. + * + * @return bool *FALSE* if no document found linked to current Roadiz document. + */ + public function getDocumentFromIndex(): bool + { + /** @var SolariumDocumentTranslation $documentTranslationItem */ + foreach ($this->documentTranslationItems as $documentTranslationItem) { + $documentTranslationItem->getDocumentFromIndex(); + } + + return true; + } + + /** + * @param Query $update + * @return $this + */ + public function createEmptyDocument(Query $update): self + { + /** @var SolariumDocumentTranslation $documentTranslationItem */ + foreach ($this->documentTranslationItems as $documentTranslationItem) { + $documentTranslationItem->createEmptyDocument($update); + } + return $this; + } + + protected function getFieldsAssoc(): array + { + return []; + } + + /** + * @param Query $update + * + * @return bool + */ + public function clean(Query $update): bool + { + /** @var SolariumDocumentTranslation $documentTranslationItem */ + foreach ($this->documentTranslationItems as $documentTranslationItem) { + $documentTranslationItem->clean($update); + } + + return true; + } + + public function indexAndCommit(): ?ResultInterface + { + $lastResult = null; + /** @var SolariumDocumentTranslation $documentTranslationItem */ + foreach ($this->documentTranslationItems as $documentTranslationItem) { + $lastResult = $documentTranslationItem->indexAndCommit(); + } + return $lastResult; + } + + /** + * @return ResultInterface|null + * @throws \Exception + */ + public function updateAndCommit(): ?ResultInterface + { + $lastResult = null; + /** @var SolariumDocumentTranslation $documentTranslationItem */ + foreach ($this->documentTranslationItems as $documentTranslationItem) { + $lastResult = $documentTranslationItem->updateAndCommit(); + } + + return $lastResult; + } + + /** + * @param Query $update + * + * @throws \Exception + */ + public function update(Query $update): void + { + /** @var SolariumDocumentTranslation $documentTranslationItem */ + foreach ($this->documentTranslationItems as $documentTranslationItem) { + $documentTranslationItem->update($update); + } + } + + /** + * @param Query $update + * + * @return bool + */ + public function remove(Query $update): bool + { + /** @var SolariumDocumentTranslation $documentTranslationItem */ + foreach ($this->documentTranslationItems as $documentTranslationItem) { + $documentTranslationItem->remove($update); + } + return true; + } + + /** + * @inheritdoc + */ + public function removeAndCommit(): void + { + /** @var SolariumDocumentTranslation $documentTranslationItem */ + foreach ($this->documentTranslationItems as $documentTranslationItem) { + $documentTranslationItem->removeAndCommit(); + } + } + + /** + * @inheritdoc + */ + public function cleanAndCommit(): void + { + /** @var SolariumDocumentTranslation $documentTranslationItem */ + foreach ($this->documentTranslationItems as $documentTranslationItem) { + $documentTranslationItem->cleanAndCommit(); + } + } + + /** + * @inheritdoc + */ + public function index(): bool + { + /** @var SolariumDocumentTranslation $documentTranslationItem */ + foreach ($this->documentTranslationItems as $documentTranslationItem) { + $documentTranslationItem->index(); + } + + return true; + } +} diff --git a/lib/RoadizCoreBundle/src/SearchEngine/SolariumDocumentTranslation.php b/lib/RoadizCoreBundle/src/SearchEngine/SolariumDocumentTranslation.php new file mode 100644 index 00000000..dc640488 --- /dev/null +++ b/lib/RoadizCoreBundle/src/SearchEngine/SolariumDocumentTranslation.php @@ -0,0 +1,68 @@ +documentTranslation = $documentTranslation; + $this->dispatcher = $dispatcher; + } + + public function getDocumentId(): int|string + { + return $this->documentTranslation->getId(); + } + + public function getFieldsAssoc(bool $subResource = false): array + { + $event = new DocumentTranslationIndexingEvent($this->documentTranslation, [], $this); + + return $this->dispatcher->dispatch($event)->getAssociations(); + } + + /** + * Remove any document linked to current node-source. + * + * @param Query $update + * @return boolean + */ + public function clean(Query $update): bool + { + $update->addDeleteQuery( + static::IDENTIFIER_KEY . ':"' . $this->documentTranslation->getId() . '"' . + '&' . static::TYPE_DISCRIMINATOR . ':"' . static::DOCUMENT_TYPE . '"' . + '&locale_s:"' . $this->documentTranslation->getTranslation()->getLocale() . '"' + ); + + return true; + } +} diff --git a/lib/RoadizCoreBundle/src/SearchEngine/SolariumFactory.php b/lib/RoadizCoreBundle/src/SearchEngine/SolariumFactory.php new file mode 100644 index 00000000..34fa3d51 --- /dev/null +++ b/lib/RoadizCoreBundle/src/SearchEngine/SolariumFactory.php @@ -0,0 +1,69 @@ +clientRegistry = $clientRegistry; + $this->logger = $searchEngineLogger; + $this->markdown = $markdown; + $this->dispatcher = $dispatcher; + $this->handlerFactory = $handlerFactory; + } + + public function createWithDocument(Document $document): SolariumDocument + { + return new SolariumDocument( + $document, + $this, + $this->clientRegistry, + $this->logger, + $this->markdown + ); + } + + public function createWithDocumentTranslation(DocumentTranslation $documentTranslation): SolariumDocumentTranslation + { + return new SolariumDocumentTranslation( + $documentTranslation, + $this->clientRegistry, + $this->dispatcher, + $this->logger, + $this->markdown + ); + } + + public function createWithNodesSources(NodesSources $nodeSource): SolariumNodeSource + { + return new SolariumNodeSource( + $nodeSource, + $this->clientRegistry, + $this->dispatcher, + $this->logger, + $this->markdown + ); + } +} diff --git a/lib/RoadizCoreBundle/src/SearchEngine/SolariumFactoryInterface.php b/lib/RoadizCoreBundle/src/SearchEngine/SolariumFactoryInterface.php new file mode 100644 index 00000000..ab59410c --- /dev/null +++ b/lib/RoadizCoreBundle/src/SearchEngine/SolariumFactoryInterface.php @@ -0,0 +1,16 @@ +nodeSource = $nodeSource; + $this->dispatcher = $dispatcher; + } + + public function getDocumentId(): int|string + { + return $this->nodeSource->getId() ?? throw new \RuntimeException('NodeSource must have an ID'); + } + + /** + * Get a key/value array representation of current node-source document. + * + * @param bool $subResource Tell when this field gathering is for a main resource indexation or a sub-resource + * + * @return array + * @throws \Exception + */ + public function getFieldsAssoc(bool $subResource = false): array + { + $event = new NodesSourcesIndexingEvent($this->nodeSource, [], $this); + + return $this->dispatcher->dispatch($event)->getAssociations(); + } + + /** + * Remove any document linked to current node-source. + * + * @param Query $update + * @return bool + */ + public function clean(Query $update): bool + { + $update->addDeleteQuery( + static::IDENTIFIER_KEY . ':"' . $this->nodeSource->getId() . '"' . + '&' . static::TYPE_DISCRIMINATOR . ':"' . static::DOCUMENT_TYPE . '"' . + '&locale_s:"' . $this->nodeSource->getTranslation()->getLocale() . '"' + ); + + return true; + } +} diff --git a/lib/RoadizCoreBundle/src/SearchEngine/SolrSearchResults.php b/lib/RoadizCoreBundle/src/SearchEngine/SolrSearchResults.php new file mode 100644 index 00000000..fe74fc47 --- /dev/null +++ b/lib/RoadizCoreBundle/src/SearchEngine/SolrSearchResults.php @@ -0,0 +1,224 @@ +response = $response; + $this->entityManager = $entityManager; + $this->position = 0; + $this->resultItems = null; + } + + /** + * @return int + * @JMS\Groups({"search_results"}) + * @JMS\VirtualProperty() + */ + public function getResultCount(): int + { + if ( + isset($this->response['response']['numFound']) + ) { + return (int) $this->response['response']['numFound']; + } + return 0; + } + + /** + * @return array + * @JMS\Groups({"search_results"}) + * @JMS\VirtualProperty() + */ + public function getResultItems(): array + { + if (null === $this->resultItems) { + $this->resultItems = []; + if ( + isset($this->response['response']['docs']) + ) { + $this->resultItems = array_filter(array_map( + function ($item) { + $object = $this->getHydratedItem($item); + if (isset($this->response["highlighting"])) { + $key = 'object'; + if ($object instanceof NodesSources) { + $key = 'nodeSource'; + } + if ($object instanceof DocumentInterface) { + $key = 'document'; + } + if ($object instanceof DocumentTranslation) { + $key = 'document'; + $object = $object->getDocument(); + } + return [ + $key => $object, + 'highlighting' => $this->getHighlighting($item['id']), + ]; + } + return $object; + }, + $this->response['response']['docs'] + )); + } + } + + return $this->resultItems; + } + + /** + * Merge collection_txt localized fields. + * + * @param string $id + * @return array|array[]|mixed + */ + protected function getHighlighting(string $id): mixed + { + $highlights = $this->response['highlighting'][$id]; + if (!isset($highlights['collection_txt'])) { + $collectionTxt = []; + foreach ($highlights as $field => $value) { + $collectionTxt = array_merge($collectionTxt, $value); + } + $highlights = array_merge($highlights, [ + 'collection_txt' => $collectionTxt + ]); + } + return $highlights; + } + + /** + * @param callable $callable + * + * @return array + */ + public function map(callable $callable): array + { + return array_map($callable, $this->getResultItems()); + } + + /** + * @param array $item + * + * @return array|object|null + */ + protected function getHydratedItem(array $item): mixed + { + if (isset($item[AbstractSolarium::TYPE_DISCRIMINATOR])) { + switch ($item[AbstractSolarium::TYPE_DISCRIMINATOR]) { + case SolariumNodeSource::DOCUMENT_TYPE: + return $this->entityManager->find( + NodesSources::class, + $item[SolariumNodeSource::IDENTIFIER_KEY] + ); + case SolariumDocumentTranslation::DOCUMENT_TYPE: + return $this->entityManager->find( + DocumentTranslation::class, + $item[SolariumDocumentTranslation::IDENTIFIER_KEY] + ); + } + } + + return $item; + } + + /** + * Return the current element + * + * @link https://php.net/manual/en/iterator.current.php + * @return mixed Can return any type. + * @since 5.0 + */ + #[\ReturnTypeWillChange] + public function current(): mixed + { + return $this->getResultItems()[$this->position]; + } + + /** + * Move forward to next element + * + * @link https://php.net/manual/en/iterator.next.php + * @return void Any returned value is ignored. + * @since 5.0 + */ + public function next(): void + { + ++$this->position; + } + + /** + * Return the key of the current element + * + * @link https://php.net/manual/en/iterator.key.php + * @return int + * @since 5.0 + */ + #[\ReturnTypeWillChange] + public function key(): int + { + return $this->position; + } + + /** + * Checks if current position is valid + * + * @link https://php.net/manual/en/iterator.valid.php + * @return bool The return value will be casted to boolean and then evaluated. + * Returns true on success or false on failure. + * @since 5.0 + */ + public function valid(): bool + { + return isset($this->getResultItems()[$this->position]); + } + + /** + * Rewind the Iterator to the first element + * + * @link https://php.net/manual/en/iterator.rewind.php + * @return void Any returned value is ignored. + * @since 5.0 + */ + public function rewind(): void + { + $this->position = 0; + } +} diff --git a/lib/RoadizCoreBundle/src/SearchEngine/Subscriber/DefaultDocumentTranslationIndexingSubscriber.php b/lib/RoadizCoreBundle/src/SearchEngine/Subscriber/DefaultDocumentTranslationIndexingSubscriber.php new file mode 100644 index 00000000..ee048e0c --- /dev/null +++ b/lib/RoadizCoreBundle/src/SearchEngine/Subscriber/DefaultDocumentTranslationIndexingSubscriber.php @@ -0,0 +1,130 @@ + ['onIndexing', 1000], + ]; + } + + public function onIndexing(DocumentTranslationIndexingEvent $event): void + { + $documentTranslation = $event->getDocumentTranslation(); + $assoc = $event->getAssociations(); + $collection = []; + $document = $documentTranslation->getDocument(); + + $assoc[AbstractSolarium::TYPE_DISCRIMINATOR] = SolariumDocumentTranslation::DOCUMENT_TYPE; + $assoc[SolariumDocumentTranslation::IDENTIFIER_KEY] = $documentTranslation->getId(); + if ($document instanceof Document) { + $assoc['document_id_i'] = $document->getId(); + $assoc['created_at_dt'] = $document->getCreatedAt() + ->setTimezone(new \DateTimeZone('UTC')) + ->format('Y-m-d\TH:i:s\Z'); + ; + $assoc['updated_at_dt'] = $document->getUpdatedAt() + ->setTimezone(new \DateTimeZone('UTC')) + ->format('Y-m-d\TH:i:s\Z'); + ; + + $copyrightValidSince = $document->getCopyrightValidSince() ?? new \DateTime('1970-01-01 00:00:00'); + $copyrightValidUntil = $document->getCopyrightValidUntil() ?? new \DateTime('9999-12-31 23:59:59'); + $assoc['copyright_valid_since_dt'] = $copyrightValidSince + ->setTimezone(new \DateTimeZone('UTC')) + ->format('Y-m-d\TH:i:s\Z'); + ; + $assoc['copyright_valid_until_dt'] = $copyrightValidUntil + ->setTimezone(new \DateTimeZone('UTC')) + ->format('Y-m-d\TH:i:s\Z'); + ; + } + $assoc['filename_s'] = $document->getFilename(); + $assoc['mime_type_s'] = $document->getMimeType(); + + $translation = $documentTranslation->getTranslation(); + $locale = $translation->getLocale(); + $assoc['locale_s'] = $locale; + $lang = \Locale::getPrimaryLanguage($locale); + + /* + * Use locale to create field name + * with right language + */ + $suffix = '_t'; + if (in_array($lang, SolariumDocumentTranslation::$availableLocalizedTextFields)) { + $suffix = '_txt_' . $lang; + } + + $assoc['title'] = $documentTranslation->getName(); + $assoc['title' . $suffix] = $documentTranslation->getName(); + + /* + * Remove ctrl characters + */ + $description = $event->getSolariumDocument()->cleanTextContent($documentTranslation->getDescription()); + $assoc['description' . $suffix] = $description; + + $assoc['copyright' . $suffix] = $documentTranslation->getCopyright(); + + $collection[] = $assoc['title']; + $collection[] = $assoc['description' . $suffix]; + $collection[] = $assoc['copyright' . $suffix]; + + /* + * `tags_txt` Must store only public, visible and user-searchable content. + */ + $visibleFolders = $document->getFolders()->filter(function (Folder $folder) { + return $folder->isVisible(); + })->toArray(); + $visibleFolderNames = []; + /** @var Folder $folder */ + foreach ($visibleFolders as $folder) { + $visibleFolderNames[] = $folder->getFolderName(); + if ($fTrans = $folder->getTranslatedFoldersByTranslation($translation)->first()) { + $visibleFolderNames[] = $fTrans->getName(); + } + } + $visibleFolderNames = array_filter(array_unique($visibleFolderNames)); + // Use tags_txt to be compatible with other data types + $assoc['tags_txt'] = $visibleFolderNames; + // Compile all tags names into a single localized text field. + $assoc['tags_txt_' . $lang] = implode(' ', $visibleFolderNames); + + /* + * `all_tags_txt` can store all folders, even technical one, this fields should not user searchable. + */ + $allFolders = $document->getFolders(); + $allFolderNames = []; + /** @var Folder $folder */ + foreach ($allFolders as $folder) { + $allFolderNames[] = $folder->getFolderName(); + } + // Use all_tags_txt to be compatible with other data types + $assoc['all_tags_txt'] = array_filter(array_unique($allFolderNames)); + + /* + * Collect data in a single field + * for global search + */ + $assoc['collection_txt'] = $collection; + // Compile all text content into a single localized text field. + $assoc['collection_txt_' . $lang] = implode(PHP_EOL, $collection); + $event->setAssociations($assoc); + } +} diff --git a/lib/RoadizCoreBundle/src/SearchEngine/Subscriber/DefaultNodesSourcesIndexingSubscriber.php b/lib/RoadizCoreBundle/src/SearchEngine/Subscriber/DefaultNodesSourcesIndexingSubscriber.php new file mode 100644 index 00000000..f4de081f --- /dev/null +++ b/lib/RoadizCoreBundle/src/SearchEngine/Subscriber/DefaultNodesSourcesIndexingSubscriber.php @@ -0,0 +1,188 @@ + ['onIndexing', 1000], + ]; + } + + public function onIndexing(NodesSourcesIndexingEvent $event): void + { + $nodeSource = $event->getNodeSource(); + $subResource = $event->isSubResource(); + $assoc = $event->getAssociations(); + $collection = []; + $node = $nodeSource->getNode(); + + if (null === $node) { + throw new \RuntimeException("No node relation found for source: " . $nodeSource->getTitle(), 1); + } + + // Need a documentType field + $assoc[SolariumNodeSource::TYPE_DISCRIMINATOR] = SolariumNodeSource::DOCUMENT_TYPE; + // Need a nodeSourceId field + $assoc[SolariumNodeSource::IDENTIFIER_KEY] = $nodeSource->getId(); + $assoc['node_type_s'] = $node->getNodeType()->getName(); + $assoc['node_name_s'] = $node->getNodeName(); + $assoc['node_status_i'] = $node->getStatus(); + $assoc['node_visible_b'] = $node->isVisible(); + + // Need a locale field + $locale = $nodeSource->getTranslation()->getLocale(); + $lang = \Locale::getPrimaryLanguage($locale); + $assoc['locale_s'] = $locale; + + /* + * Index resource title + */ + $title = $event->getSolariumDocument()->cleanTextContent($nodeSource->getTitle(), false); + $assoc['title'] = $title; + $assoc['title_txt_' . $lang] = $title; + + $assoc['created_at_dt'] = $node->getCreatedAt() + ->setTimezone(new \DateTimeZone('UTC')) + ->format('Y-m-d\TH:i:s\Z'); + $assoc['updated_at_dt'] = $node->getUpdatedAt() + ->setTimezone(new \DateTimeZone('UTC')) + ->format('Y-m-d\TH:i:s\Z'); + + if (null !== $nodeSource->getPublishedAt()) { + $assoc['published_at_dt'] = $nodeSource->getPublishedAt() + ->setTimezone(new \DateTimeZone('UTC')) + ->format('Y-m-d\TH:i:s\Z'); + } + + /* + * Do not index locale and tags if this is a sub-resource + */ + if (!$subResource) { + if ($this->canIndexTitleInCollection($nodeSource)) { + $collection[] = $title; + } + /* + * Index parent node ID and name to filter on it + */ + $parent = $node->getParent(); + if (null !== $parent) { + $assoc['node_parent_i'] = $parent->getId(); + $assoc['node_parent_s'] = $parent->getNodeName(); + } + + /* + * `tags_txt` Must store only public, visible and user-searchable content. + */ + $out = array_map( + function (Tag $tag) use ($event, $nodeSource) { + $translatedTag = $tag->getTranslatedTagsByTranslation($nodeSource->getTranslation())->first(); + $tagName = $translatedTag ? + $translatedTag->getName() : + $tag->getTagName(); + return $event->getSolariumDocument()->cleanTextContent($tagName, false); + }, + $nodeSource->getNode()->getTags()->filter(function (Tag $tag) { + return $tag->isVisible(); + })->toArray() + ); + $out = array_filter(array_unique($out)); + // Use tags_txt to be compatible with other data types + $assoc['tags_txt'] = $out; + // Compile all tags names into a single localized text field. + $assoc['tags_txt_' . $lang] = implode(' ', $out); + + /* + * `all_tags_txt` can store all tags, even technical one, this fields should not user searchable. + */ + $allOut = array_map( + function (Tag $tag) { + return $tag->getTagName(); + }, + $nodeSource->getNode()->getTags()->toArray() + ); + $allOut = array_filter(array_unique($allOut)); + // Use all_tags_txt to be compatible with other data types + $assoc['all_tags_txt'] = $allOut; + } + + $criteria = new Criteria(); + $criteria->andWhere(Criteria::expr()->eq("type", AbstractField::BOOLEAN_T)); + $booleanFields = $node->getNodeType()->getFields()->matching($criteria); + + /** @var NodeTypeField $booleanField */ + foreach ($booleanFields as $booleanField) { + $name = $booleanField->getName(); + $name .= '_b'; + $getter = $booleanField->getGetterName(); + $assoc[$name] = $nodeSource->$getter(); + } + + $searchableFields = $node->getNodeType()->getSearchableFields(); + /** @var NodeTypeField $field */ + foreach ($searchableFields as $field) { + $name = $field->getName(); + $getter = $field->getGetterName(); + $content = $nodeSource->$getter(); + /* + * Strip markdown syntax + */ + $content = $event->getSolariumDocument()->cleanTextContent($content); + /* + * Use locale to create field name + * with right language + */ + if (in_array($lang, SolariumNodeSource::$availableLocalizedTextFields)) { + $name .= '_txt_' . $lang; + } else { + $name .= '_t'; + } + + $assoc[$name] = $content; + $collection[] = $content; + } + /* + * Collect data in a single field + * for global search + */ + $assoc['collection_txt'] = $collection; + // Compile all text content into a single localized text field. + $assoc['collection_txt_' . $lang] = implode(PHP_EOL, $collection); + $event->setAssociations($assoc); + } + + /** + * @param NodesSources $source + * @return bool + */ + protected function canIndexTitleInCollection(NodesSources $source): bool + { + if (method_exists($source, 'getHideTitle')) { + return !((bool) $source->getHideTitle()); + } + if (method_exists($source, 'getShowTitle')) { + return ((bool) $source->getShowTitle()); + } + + if (null !== $source->getNode() && $source->getNode()->getNodeType()) { + return $source->getNode()->getNodeType()->isSearchable(); + } + return true; + } +} diff --git a/lib/RoadizCoreBundle/src/SearchEngine/Subscriber/SolariumSubscriber.php b/lib/RoadizCoreBundle/src/SearchEngine/Subscriber/SolariumSubscriber.php new file mode 100644 index 00000000..05868eca --- /dev/null +++ b/lib/RoadizCoreBundle/src/SearchEngine/Subscriber/SolariumSubscriber.php @@ -0,0 +1,178 @@ +messageBus = $messageBus; + } + + public static function getSubscribedEvents(): array + { + return [ + NodeUpdatedEvent::class => 'onSolariumNodeUpdate', + 'workflow.node.completed' => ['onSolariumNodeWorkflowComplete'], + NodeVisibilityChangedEvent::class => 'onSolariumNodeUpdate', + NodesSourcesUpdatedEvent::class => 'onSolariumSingleUpdate', + NodesSourcesDeletedEvent::class => 'onSolariumSingleDelete', + NodeDeletedEvent::class => 'onSolariumNodeDelete', + NodeUndeletedEvent::class => 'onSolariumNodeUpdate', + NodeTaggedEvent::class => 'onSolariumNodeUpdate', + NodeCreatedEvent::class => 'onSolariumNodeUpdate', + TagUpdatedEvent::class => 'onSolariumTagUpdate', // Possibly too greedy if lots of nodes tagged + DocumentCreatedEvent::class => 'onSolariumDocumentUpdate', + DocumentTranslationUpdatedEvent::class => 'onSolariumDocumentUpdate', + DocumentInFolderEvent::class => 'onSolariumDocumentUpdate', + DocumentOutFolderEvent::class => 'onSolariumDocumentUpdate', + DocumentUpdatedEvent::class => 'onSolariumDocumentUpdate', + DocumentDeletedEvent::class => 'onSolariumDocumentDelete', + FolderUpdatedEvent::class => 'onSolariumFolderUpdate', // Possibly too greedy if lots of docs tagged + ]; + } + + /** + * @param Event $event + */ + public function onSolariumNodeWorkflowComplete(Event $event): void + { + $node = $event->getSubject(); + if ($node instanceof Node) { + $this->messageBus->dispatch(new Envelope(new SolrReindexMessage(Node::class, $node->getId()))); + } + } + + /** + * Update or create Solr document for current Node-source. + * + * @param NodesSourcesUpdatedEvent $event + * + * @throws \Exception + */ + public function onSolariumSingleUpdate(NodesSourcesUpdatedEvent $event): void + { + $this->messageBus->dispatch(new Envelope(new SolrReindexMessage(NodesSources::class, $event->getNodeSource()->getId()))); + } + + /** + * Delete solr document for current Node-source. + * + * @param NodesSourcesDeletedEvent $event + */ + public function onSolariumSingleDelete(NodesSourcesDeletedEvent $event): void + { + $this->messageBus->dispatch(new Envelope(new SolrDeleteMessage(NodesSources::class, $event->getNodeSource()->getId()))); + } + + /** + * Delete solr documents for each Node sources. + * + * @param NodeDeletedEvent $event + */ + public function onSolariumNodeDelete(NodeDeletedEvent $event): void + { + $this->messageBus->dispatch(new Envelope(new SolrDeleteMessage(Node::class, $event->getNode()->getId()))); + } + + /** + * Update or create solr documents for each Node sources. + * + * @param FilterNodeEvent $event + * + * @throws \Exception + */ + public function onSolariumNodeUpdate(FilterNodeEvent $event): void + { + $this->messageBus->dispatch(new Envelope(new SolrReindexMessage(Node::class, $event->getNode()->getId()))); + } + + + /** + * Delete solr documents for each Document translation. + * + * @param FilterDocumentEvent $event + */ + public function onSolariumDocumentDelete(FilterDocumentEvent $event): void + { + $document = $event->getDocument(); + if ($document instanceof Document) { + $this->messageBus->dispatch(new Envelope(new SolrDeleteMessage(Document::class, $document->getId()))); + } + } + + /** + * Update or create solr documents for each Document translation. + * + * @param FilterDocumentEvent $event + * + * @throws \Exception + */ + public function onSolariumDocumentUpdate(FilterDocumentEvent $event): void + { + $document = $event->getDocument(); + if ($document instanceof Document) { + $this->messageBus->dispatch(new Envelope(new SolrReindexMessage(Document::class, $document->getId()))); + } + } + + /** + * Update solr documents linked to current event Tag. + * + * @param TagUpdatedEvent $event + * + * @throws \Exception + * @deprecated This can lead to a timeout if more than 500 nodes use that tag! + */ + public function onSolariumTagUpdate(TagUpdatedEvent $event): void + { + $this->messageBus->dispatch(new Envelope(new SolrReindexMessage(Tag::class, $event->getTag()->getId()))); + } + + /** + * Update solr documents linked to current event Folder. + * + * @param FolderUpdatedEvent $event + * + * @throws \Exception + * @deprecated This can lead to a timeout if more than 500 documents use that folder! + */ + public function onSolariumFolderUpdate(FolderUpdatedEvent $event): void + { + $this->messageBus->dispatch(new Envelope(new SolrReindexMessage(Folder::class, $event->getFolder()->getId()))); + } +} diff --git a/lib/RoadizCoreBundle/src/Security/Authentication/JwtAuthenticationSuccessHandler.php b/lib/RoadizCoreBundle/src/Security/Authentication/JwtAuthenticationSuccessHandler.php new file mode 100644 index 00000000..3230aab3 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Security/Authentication/JwtAuthenticationSuccessHandler.php @@ -0,0 +1,39 @@ +decorated = $decorated; + $this->managerRegistry = $managerRegistry; + } + + public function onAuthenticationSuccess(Request $request, TokenInterface $token): Response + { + $response = $this->decorated->onAuthenticationSuccess($request, $token); + $user = $token->getUser(); + + if ($user instanceof User) { + $user->setLastLogin(new \DateTime()); + $this->managerRegistry->getManager()->flush(); + } + return $response; + } +} diff --git a/lib/RoadizCoreBundle/src/Security/Authentication/Manager/LoginAttemptManager.php b/lib/RoadizCoreBundle/src/Security/Authentication/Manager/LoginAttemptManager.php new file mode 100644 index 00000000..5e1e7ad1 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Security/Authentication/Manager/LoginAttemptManager.php @@ -0,0 +1,160 @@ +requestStack = $requestStack; + $this->logger = $logger; + $this->managerRegistry = $managerRegistry; + } + + /** + * @param string $username + * @throws \Doctrine\ORM\NoResultException + * @throws \Doctrine\ORM\NonUniqueResultException + */ + public function checkLoginAttempts(string $username): void + { + /* + * Checks if there are more than 10 failed attempts + * from same IP address in the last 20 minutes + */ + if ( + $this->getLoginAttemptRepository()->isIpAddressBlocked( + $this->requestStack->getMainRequest()->getClientIp(), + $this->getIpAttemptGraceTime(), + $this->getIpAttemptCount() + ) + ) { + throw new TooManyLoginAttemptsException( + 'Too many login attempts for current IP address, wait before trying again.', + Response::HTTP_TOO_MANY_REQUESTS + ); + } + if ($this->getLoginAttemptRepository()->isUsernameBlocked($username)) { + throw new TooManyLoginAttemptsException( + 'Too many login attempts for this username, wait before trying again.', + Response::HTTP_TOO_MANY_REQUESTS + ); + } + } + + /** + * @param string $username + * + * @return $this + * @throws \Exception + */ + public function onFailedLoginAttempt(string $username): LoginAttemptManager + { + $manager = $this->managerRegistry->getManagerForClass(LoginAttempt::class); + if (null === $manager) { + throw new \RuntimeException('No manager found for class ' . LoginAttempt::class); + } + $loginAttempt = $this->getLoginAttemptRepository()->findOrCreateOneByIpAddressAndUsername( + $this->requestStack->getMainRequest()->getClientIp(), + $username + ); + + $loginAttempt->addAttemptCount(); + $blocksUntil = new \DateTime(); + + if ($loginAttempt->getAttemptCount() >= 9) { + $blocksUntil->add(new \DateInterval('PT30M')); + $loginAttempt->setBlocksLoginUntil($blocksUntil); + $this->logger->info(sprintf( + 'Client has been blocked from login until %s', + $blocksUntil->format('Y-m-d H:i:s') + )); + } elseif ($loginAttempt->getAttemptCount() >= 6) { + $blocksUntil->add(new \DateInterval('PT15M')); + $loginAttempt->setBlocksLoginUntil($blocksUntil); + $this->logger->info(sprintf( + 'Client has been blocked from login until %s', + $blocksUntil->format('Y-m-d H:i:s') + )); + } elseif ($loginAttempt->getAttemptCount() >= 3) { + $blocksUntil->add(new \DateInterval('PT3M')); + $loginAttempt->setBlocksLoginUntil($blocksUntil); + $this->logger->info(sprintf( + 'Client has been blocked from login until %s', + $blocksUntil->format('Y-m-d H:i:s') + )); + } + $manager->flush(); + return $this; + } + + /** + * @return LoginAttemptRepository + */ + public function getLoginAttemptRepository(): LoginAttemptRepository + { + if (null === $this->loginAttemptRepository) { + $this->loginAttemptRepository = $this->managerRegistry->getRepository(LoginAttempt::class); + } + return $this->loginAttemptRepository; + } + + /** + * @param string $username + * + * @return $this + */ + public function onSuccessLoginAttempt(string $username) + { + $this->getLoginAttemptRepository()->resetLoginAttempts( + $this->requestStack->getMainRequest()->getClientIp(), + $username + ); + return $this; + } + + /** + * @return int + */ + public function getIpAttemptGraceTime(): int + { + return $this->ipAttemptGraceTime; + } + + /** + * @return int + */ + public function getIpAttemptCount(): int + { + return $this->ipAttemptCount; + } +} diff --git a/lib/RoadizCoreBundle/src/Security/Authentication/RoadizAuthenticator.php b/lib/RoadizCoreBundle/src/Security/Authentication/RoadizAuthenticator.php new file mode 100644 index 00000000..0d4673d4 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Security/Authentication/RoadizAuthenticator.php @@ -0,0 +1,139 @@ +urlGenerator = $urlGenerator; + $this->managerRegistry = $managerRegistry; + $this->logger = $logger; + $this->usernamePath = $usernamePath; + $this->passwordPath = $passwordPath; + } + + public function authenticate(Request $request): Passport + { + $credentials = $this->getCredentials($request); + $request->getSession()->set(Security::LAST_USERNAME, $credentials['username']); + + return new Passport( + new UserBadge($credentials['username']), + new PasswordCredentials($credentials['password']), + [ + new CsrfTokenBadge('authenticate', $request->get('_csrf_token')), + new RememberMeBadge(), + ] + ); + } + + public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): Response + { + $user = $token->getUser(); + + if ($user instanceof User) { + $user->setLastLogin(new \DateTime('now')); + $manager = $this->managerRegistry->getManagerForClass(User::class); + if (null !== $manager) { + $manager->flush(); + } + } + + if ($targetPath = $this->getTargetPath($request->getSession(), $firewallName)) { + return new RedirectResponse($targetPath); + } + + return new RedirectResponse($this->urlGenerator->generate('adminHomePage')); + } + + public function onAuthenticationFailure(Request $request, AuthenticationException $exception): Response + { + $credentials = $this->getCredentials($request); + $ipAddress = $request->getClientIp(); + $this->logger->error($exception->getMessage(), [ + 'username' => $credentials['username'], + 'ipAddress' => $ipAddress + ]); + + return parent::onAuthenticationFailure($request, $exception); + } + + + protected function getLoginUrl(Request $request): string + { + return $this->urlGenerator->generate(self::LOGIN_ROUTE); + } + + /** + * @param Request $request + * @return array + */ + private function getCredentials(Request $request): array + { + $credentials = []; + try { + $credentials['username'] = $request->request->get($this->usernamePath); + + if (!\is_string($credentials['username'])) { + throw new BadRequestHttpException(sprintf('The key "%s" must be a string.', $this->usernamePath)); + } + + if (\strlen($credentials['username']) > Security::MAX_USERNAME_LENGTH) { + throw new BadCredentialsException('Invalid username.'); + } + } catch (AccessException $e) { + throw new BadRequestHttpException(sprintf('The key "%s" must be provided.', $this->usernamePath), $e); + } + + try { + $credentials['password'] = $request->request->get($this->passwordPath); + + if (!\is_string($credentials['password'])) { + throw new BadRequestHttpException(sprintf('The key "%s" must be a string.', $this->passwordPath)); + } + } catch (AccessException $e) { + throw new BadRequestHttpException(sprintf('The key "%s" must be provided.', $this->passwordPath), $e); + } + + return $credentials; + } +} diff --git a/lib/RoadizCoreBundle/src/Security/Authorization/AccessDeniedHandler.php b/lib/RoadizCoreBundle/src/Security/Authorization/AccessDeniedHandler.php new file mode 100644 index 00000000..adf23d44 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Security/Authorization/AccessDeniedHandler.php @@ -0,0 +1,85 @@ +logger = $logger ?? new NullLogger(); + $this->urlGenerator = $urlGenerator; + $this->redirectRoute = $redirectRoute; + $this->redirectParameters = $redirectParameters; + } + + /** + * Handles an access denied failure redirecting to home page + * + * @param Request $request + * @param AccessDeniedException $accessDeniedException + * + * @return Response|null may return null + */ + public function handle(Request $request, AccessDeniedException $accessDeniedException): ?Response + { + $this->logger->error('User tried to access: ' . $request->getUri()); + + $returnJson = $request->isXmlHttpRequest() || + $request->getRequestFormat() === 'json' || + ( + count($request->getAcceptableContentTypes()) === 1 && + $request->getAcceptableContentTypes()[0] === 'application/json' + ) || + ($request->attributes->has('_format') && $request->attributes->get('_format') === 'json'); + + if ($returnJson) { + return new JsonResponse( + [ + 'message' => $accessDeniedException->getMessage(), + 'trace' => $accessDeniedException->getTraceAsString(), + 'exception' => get_class($accessDeniedException), + ], + Response::HTTP_FORBIDDEN + ); + } else { + if ('' !== $this->redirectRoute) { + $redirectUrl = $this->urlGenerator->generate($this->redirectRoute, $this->redirectParameters); + } else { + $redirectUrl = $request->getBaseUrl(); + } + // Forbidden code should be set on final response, not the redirection! + return new RedirectResponse($redirectUrl, Response::HTTP_FOUND); + } + } +} diff --git a/lib/RoadizCoreBundle/src/Security/Authorization/Chroot/NodeChrootChainResolver.php b/lib/RoadizCoreBundle/src/Security/Authorization/Chroot/NodeChrootChainResolver.php new file mode 100644 index 00000000..2e836a50 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Security/Authorization/Chroot/NodeChrootChainResolver.php @@ -0,0 +1,68 @@ + + */ + private array $resolvers; + + /** + * @param array $resolvers + */ + public function __construct(array $resolvers) + { + $this->resolvers = $resolvers; + foreach ($this->resolvers as $resolver) { + if (!($resolver instanceof NodeChrootResolver)) { + throw new \InvalidArgumentException('Resolver must implements ' . NodeChrootResolver::class); + } + } + } + + /** + * @param User|UserInterface|string|null $user + * + * @return Node|null + */ + public function getChroot($user = null): ?Node + { + /** @var NodeChrootResolver $resolver */ + foreach ($this->resolvers as $resolver) { + if ($resolver->supports($user)) { + return $resolver->getChroot($user); + } + } + return null; + } + + /** + * @param User|UserInterface|string|null $user + * + * @return bool + */ + public function supports($user): bool + { + /** @var NodeChrootResolver $resolver */ + foreach ($this->resolvers as $resolver) { + if ($resolver->supports($user)) { + return true; + } + } + return false; + } +} diff --git a/lib/RoadizCoreBundle/src/Security/Authorization/Chroot/NodeChrootResolver.php b/lib/RoadizCoreBundle/src/Security/Authorization/Chroot/NodeChrootResolver.php new file mode 100644 index 00000000..5a0a4476 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Security/Authorization/Chroot/NodeChrootResolver.php @@ -0,0 +1,33 @@ +getChroot(); + } +} diff --git a/lib/RoadizCoreBundle/src/Security/Authorization/Voter/GroupVoter.php b/lib/RoadizCoreBundle/src/Security/Authorization/Voter/GroupVoter.php new file mode 100644 index 00000000..10bcfb52 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Security/Authorization/Voter/GroupVoter.php @@ -0,0 +1,106 @@ +roleHierarchy = $roleHierarchy; + parent::__construct($prefix); + } + + protected function supports(string $attribute, mixed $subject): bool + { + return $subject instanceof Group; + } + + protected function extractRoles(TokenInterface $token): array + { + return $this->roleHierarchy->getReachableRoleNames($token->getRoleNames()); + } + + /** + * @inheritDoc + */ + public function vote(TokenInterface $token, $subject, array $attributes): int + { + $result = VoterInterface::ACCESS_ABSTAIN; + $roles = $this->extractRoles($token); + $user = $token->getUser(); + + foreach ($attributes as $attribute) { + if (!($attribute instanceof Group)) { + return VoterInterface::ACCESS_ABSTAIN; + } + + $result = VoterInterface::ACCESS_GRANTED; + + /* + * If super-admin, group is always granted + */ + if (\in_array('ROLE_SUPER_ADMIN', $roles) || \in_array('ROLE_SUPERADMIN', $roles)) { + return $result; + } + /* + * If user is part of current tested group, grant it. + */ + if ( + $user instanceof User && + $user->getGroups()->exists(function ($key, Group $group) use ($attribute) { + return $attribute->getId() === $group->getId(); + }) + ) { + return $result; + } + + /* + * Test if user has all roles contained in Group to grant it access. + */ + foreach ($this->extractGroupRoles($attribute) as $role) { + if (!$this->isRoleContained($role, $roles)) { + $result = VoterInterface::ACCESS_DENIED; + } + } + } + + return $result; + } + + /** + * @param Group $group + * + * @return string[] + */ + protected function extractGroupRoles(Group $group): array + { + return $this->roleHierarchy->getReachableRoleNames($group->getRoles()); + } + + /** + * @param string $role + * @param string[] $roles + * + * @return bool + */ + protected function isRoleContained(string $role, array $roles): bool + { + foreach ($roles as $singleRole) { + if ($role === $singleRole) { + return true; + } + } + return false; + } +} diff --git a/lib/RoadizCoreBundle/src/Security/Authorization/Voter/RealmVoter.php b/lib/RoadizCoreBundle/src/Security/Authorization/Voter/RealmVoter.php new file mode 100644 index 00000000..7e61ec29 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Security/Authorization/Voter/RealmVoter.php @@ -0,0 +1,103 @@ +security = $security; + $this->requestStack = $requestStack; + } + + public function supportsAttribute(string $attribute): bool + { + return $attribute === self::READ; + } + + protected function supports(string $attribute, $subject): bool + { + return $this->supportsAttribute($attribute) && $subject instanceof RealmInterface; + } + + /** + * @param string $attribute + * @param Realm $subject + * @param TokenInterface $token + * @return bool + */ + public function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool + { + switch ($subject->getType()) { + case RealmInterface::TYPE_PLAIN_PASSWORD: + return $this->voteForPassword($attribute, $subject, $token); + case RealmInterface::TYPE_USER: + return $this->voteForUser($attribute, $subject, $token); + case RealmInterface::TYPE_ROLE: + return $this->voteForRole($attribute, $subject, $token); + } + return false; + } + + /** + * @param string $attribute + * @param RealmInterface $subject + * @param TokenInterface $token + * @return bool + */ + private function voteForRole(string $attribute, $subject, TokenInterface $token): bool + { + if (null === $role = $subject->getRole()) { + return false; + } + return $this->security->isGranted($role); + } + + /** + * @param string $attribute + * @param RealmInterface $subject + * @param TokenInterface $token + * @return bool + */ + private function voteForUser(string $attribute, $subject, TokenInterface $token): bool + { + if ($subject->getUsers()->count() === 0 || null === $token->getUser()) { + return false; + } + return $subject->getUsers()->exists(function ($key, UserInterface $user) use ($token) { + return $user->getUserIdentifier() === $token->getUserIdentifier(); + }); + } + + /** + * @param string $attribute + * @param RealmInterface $subject + * @param TokenInterface $token + * @return bool + */ + private function voteForPassword(string $attribute, $subject, TokenInterface $token): bool + { + $request = $this->requestStack->getCurrentRequest(); + if (null === $request || empty($subject->getPlainPassword())) { + return false; + } + return $request->query->has(self::PASSWORD_QUERY_PARAMETER) && + $request->query->get(self::PASSWORD_QUERY_PARAMETER) === $subject->getPlainPassword(); + } +} diff --git a/lib/RoadizCoreBundle/src/Security/Authorization/Voter/RoleArrayVoter.php b/lib/RoadizCoreBundle/src/Security/Authorization/Voter/RoleArrayVoter.php new file mode 100644 index 00000000..21ae892f --- /dev/null +++ b/lib/RoadizCoreBundle/src/Security/Authorization/Voter/RoleArrayVoter.php @@ -0,0 +1,53 @@ +extractRoles($token); + + foreach ($attributes as $attribute) { + if (!\is_array($attribute)) { + continue; + } + + foreach ($attribute as $singleAttribute) { + if (!\is_string($singleAttribute)) { + continue; + } + + if ('ROLE_PREVIOUS_ADMIN' === $singleAttribute) { + trigger_deprecation('symfony/security-core', '5.1', 'The ROLE_PREVIOUS_ADMIN role is deprecated and will be removed in version 6.0, use the IS_IMPERSONATOR attribute instead.'); + } + + $result = VoterInterface::ACCESS_DENIED; + foreach ($roles as $role) { + if ($singleAttribute === $role) { + return VoterInterface::ACCESS_GRANTED; + } + } + } + } + + return $result; + } +} diff --git a/lib/RoadizCoreBundle/src/Security/Authorization/Voter/SuperAdminRoleHierarchyVoter.php b/lib/RoadizCoreBundle/src/Security/Authorization/Voter/SuperAdminRoleHierarchyVoter.php new file mode 100644 index 00000000..e893fcbb --- /dev/null +++ b/lib/RoadizCoreBundle/src/Security/Authorization/Voter/SuperAdminRoleHierarchyVoter.php @@ -0,0 +1,45 @@ +managerRegistry = $managerRegistry; + } + + protected function extractRoles(TokenInterface $token): array + { + $roleNames = parent::extractRoles($token); + if ($this->isSuperAdmin($token)) { + $roleNames = array_merge( + $roleNames, + $this->managerRegistry->getRepository(Role::class)->getAllBasicRoleName() + ); + } + return $roleNames; + } + + private function isSuperAdmin(TokenInterface $token): bool + { + $roleNames = parent::extractRoles($token); + if (\in_array('ROLE_SUPER_ADMIN', $roleNames) || \in_array('ROLE_SUPERADMIN', $roleNames)) { + return true; + } + return false; + } +} diff --git a/lib/RoadizCoreBundle/src/Security/Blacklist/Top500Provider.php b/lib/RoadizCoreBundle/src/Security/Blacklist/Top500Provider.php new file mode 100644 index 00000000..34404252 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Security/Blacklist/Top500Provider.php @@ -0,0 +1,522 @@ +managerRegistry = $managerRegistry; + } + + /** + * @param string $username + * @return UserInterface + * @deprecated since Symfony 5.3, use loadUserByIdentifier() instead + */ + public function loadUserByUsername(string $username): UserInterface + { + return $this->loadUserByUsernameOrEmail($username); + } + + protected function loadUserByUsernameOrEmail(string $identifier): UserInterface + { + /** @var User|null $user */ + $user = $this->managerRegistry + ->getRepository(User::class) + ->findOneBy(['username' => $identifier]); + + if ($user === null) { + /** @var User|null $user */ + $user = $this->managerRegistry + ->getRepository(User::class) + ->findOneBy(['email' => $identifier]); + } + + if ($user !== null) { + return $user; + } else { + throw new UserNotFoundException(); + } + } + + public function loadUserByIdentifier(string $identifier): UserInterface + { + return $this->loadUserByUsernameOrEmail($identifier); + } + + /** + * Refreshes the user for the account interface. + * + * It is up to the implementation to decide if the user data should be + * totally reloaded (e.g. from the database), or if the RZ\Roadiz\CoreBundle\Entity\User + * object can just be merged into some internal array of users / identity + * map. + * + * @param UserInterface $user + * @return User + * @throws UnsupportedUserException + */ + public function refreshUser(UserInterface $user): UserInterface + { + if ($user instanceof User) { + $manager = $this->managerRegistry->getManagerForClass(User::class); + /** @var User|null $refreshUser */ + $refreshUser = $manager->find(User::class, (int) $user->getId()); + if ( + $refreshUser !== null && + $refreshUser->isEnabled() && + $refreshUser->isAccountNonExpired() && + $refreshUser->isAccountNonLocked() + ) { + // Always refresh User from database: too much related entities to rely only on token. + return $refreshUser; + } else { + throw new UserNotFoundException('Token user does not exist anymore, authenticate again…'); + } + } + throw new UnsupportedUserException(); + } + + /** + * Whether this provider supports the given user class + * + * @param class-string $class + * @return bool + */ + public function supportsClass($class): bool + { + return $class === User::class; + } +} diff --git a/lib/RoadizCoreBundle/src/Security/User/UserViewer.php b/lib/RoadizCoreBundle/src/Security/User/UserViewer.php new file mode 100644 index 00000000..e8681701 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Security/User/UserViewer.php @@ -0,0 +1,150 @@ +settingsBag = $settingsBag; + $this->translator = $translator; + $this->emailManager = $emailManager; + $this->logger = $logger; + $this->urlGenerator = $urlGenerator; + } + + /** + * Send email to reset user password. + * + * @param string|NodesSources $route + * @param string $htmlTemplate + * @param string $txtTemplate + * + * @return bool + * @throws \Exception + */ + public function sendPasswordResetLink( + $route = 'loginResetPage', + string $htmlTemplate = '@RoadizCore/email/users/reset_password_email.html.twig', + string $txtTemplate = '@RoadizCore/email/users/reset_password_email.txt.twig' + ): bool { + if (null === $this->user) { + throw new \InvalidArgumentException('User should be defined before sending email.'); + } + $emailContact = $this->getContactEmail(); + $siteName = $this->getSiteName(); + + if (is_string($route)) { + $resetLink = $this->urlGenerator->generate( + $route, + [ + 'token' => $this->user->getConfirmationToken(), + ], + UrlGeneratorInterface::ABSOLUTE_URL + ); + } else { + $resetLink = $this->urlGenerator->generate( + RouteObjectInterface::OBJECT_BASED_ROUTE_NAME, + [ + RouteObjectInterface::ROUTE_OBJECT => $route, + 'token' => $this->user->getConfirmationToken(), + ], + UrlGeneratorInterface::ABSOLUTE_URL + ); + } + $this->emailManager->setAssignation([ + 'resetLink' => $resetLink, + 'user' => $this->user, + 'site' => $siteName, + 'mailContact' => $emailContact, + ]); + $this->emailManager->setEmailTemplate($htmlTemplate); + $this->emailManager->setEmailPlainTextTemplate($txtTemplate); + $this->emailManager->setSubject($this->translator->trans( + 'reset.password.request' + )); + $this->emailManager->setReceiver($this->user->getEmail()); + $this->emailManager->setSender([$emailContact => $siteName]); + + try { + // Send the message + $this->emailManager->send(); + return true; + } catch (TransportException $e) { + // Silent error not to prevent user creation if mailer is not configured + $this->logger->error('Unable to send password reset link', [ + 'exception' => get_class($e), + 'message' => $e->getMessage(), + ]); + return false; + } + } + + /** + * @return string + */ + protected function getContactEmail(): string + { + $emailContact = $this->settingsBag->get('email_sender') ?? ''; + if (empty($emailContact)) { + $emailContact = "noreply@roadiz.io"; + } + + return $emailContact; + } + + /** + * @return string + */ + protected function getSiteName(): string + { + $siteName = $this->settingsBag->get('site_name') ?? ''; + if (empty($siteName)) { + $siteName = "Unnamed site"; + } + + return $siteName; + } + + /** + * @return null|User + */ + public function getUser(): ?User + { + return $this->user; + } + + /** + * @param null|User $user + * @return UserViewer + */ + public function setUser(?User $user) + { + $this->user = $user; + return $this; + } +} diff --git a/lib/RoadizCoreBundle/src/Serializer/CircularReferenceHandler.php b/lib/RoadizCoreBundle/src/Serializer/CircularReferenceHandler.php new file mode 100644 index 00000000..eaec4ef9 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Serializer/CircularReferenceHandler.php @@ -0,0 +1,36 @@ +iriConverter = $iriConverter; + } + + /** + * @param mixed $object + * @return string + */ + public function __invoke($object, string $format = null, array $context = []) + { + try { + return $this->iriConverter->getIriFromItem($object); + } catch (\InvalidArgumentException $exception) { + if (is_object($object) && method_exists($object, 'getId')) { + return (string) $object->getId(); + } + return ''; + } + } +} diff --git a/lib/RoadizCoreBundle/src/Serializer/Normalizer/AbstractPathNormalizer.php b/lib/RoadizCoreBundle/src/Serializer/Normalizer/AbstractPathNormalizer.php new file mode 100644 index 00000000..e66fb474 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Serializer/Normalizer/AbstractPathNormalizer.php @@ -0,0 +1,61 @@ +decorated = $decorated; + $this->urlGenerator = $urlGenerator; + } + + public function supportsNormalization($data, $format = null, array $context = []): bool + { + return $this->decorated->supportsNormalization($data, $format); + } + + public function supportsDenormalization($data, $type, $format = null): bool + { + return $this->decorated->supportsDenormalization($data, $type, $format); + } + + /** + * @param mixed $data + * @param string $class + * @param string|null $format + * @param array $context + * @return mixed + * @throws \Symfony\Component\Serializer\Exception\ExceptionInterface + */ + public function denormalize($data, $class, $format = null, array $context = []) + { + return $this->decorated->denormalize($data, $class, $format, $context); + } + + public function setSerializer(SerializerInterface $serializer): void + { + if ($this->decorated instanceof SerializerAwareInterface) { + $this->decorated->setSerializer($serializer); + } + } +} diff --git a/lib/RoadizCoreBundle/src/Serializer/Normalizer/AttributeValueNormalizer.php b/lib/RoadizCoreBundle/src/Serializer/Normalizer/AttributeValueNormalizer.php new file mode 100644 index 00000000..543d865d --- /dev/null +++ b/lib/RoadizCoreBundle/src/Serializer/Normalizer/AttributeValueNormalizer.php @@ -0,0 +1,53 @@ +decorated->normalize($object, $format, $context); + if ($object instanceof AttributeValue && is_array($data)) { + /** @var array $serializationGroups */ + $serializationGroups = isset($context['groups']) && is_array($context['groups']) ? $context['groups'] : []; + + $data['type'] = $object->getType(); + $data['code'] = $object->getAttribute()->getCode(); + $data['color'] = $object->getAttribute()->getColor(); + + if (isset($context['translation']) && $context['translation'] instanceof TranslationInterface) { + $translatedData = $object->getAttributeValueTranslation($context['translation']); + $data['label'] = $object->getAttribute()->getLabelOrCode($context['translation']); + if ($translatedData instanceof AttributeValueTranslationInterface) { + $data['value'] = $translatedData->getValue(); + } + } + + if (\in_array('attribute_documents', $serializationGroups, true)) { + $documentsContext = $context; + $documentsContext['groups'] = ['document_display']; + $data['documents'] = array_map(function (DocumentInterface $document) use ($format, $documentsContext) { + return $this->decorated->normalize($document, $format, $documentsContext); + }, $object->getAttribute()->getDocuments()->toArray()); + } + } + return $data; + } +} diff --git a/lib/RoadizCoreBundle/src/Serializer/Normalizer/CustomFormNormalizer.php b/lib/RoadizCoreBundle/src/Serializer/Normalizer/CustomFormNormalizer.php new file mode 100644 index 00000000..114414ea --- /dev/null +++ b/lib/RoadizCoreBundle/src/Serializer/Normalizer/CustomFormNormalizer.php @@ -0,0 +1,47 @@ +decorated->normalize($object, $format, $context); + if ($object instanceof CustomForm && is_array($data)) { + $data['name'] = $object->getDisplayName(); + $data['color'] = $object->getColor(); + $data['description'] = $object->getDescription(); + $data['slug'] = (new AsciiSlugger())->slug($object->getName())->snake()->toString(); + $data['open'] = $object->isFormStillOpen(); + + if ( + isset($context['groups']) && + \in_array('urls', $context['groups'], true) + ) { + $data['definitionUrl'] = $this->urlGenerator->generate('api_custom_forms_item_definition', [ + 'id' => $object->getId() + ]); + $data['postUrl'] = $this->urlGenerator->generate('api_custom_forms_item_post', [ + 'id' => $object->getId() + ]); + } + } + return $data; + } +} diff --git a/lib/RoadizCoreBundle/src/Serializer/Normalizer/DocumentNormalizer.php b/lib/RoadizCoreBundle/src/Serializer/Normalizer/DocumentNormalizer.php new file mode 100644 index 00000000..918e6aee --- /dev/null +++ b/lib/RoadizCoreBundle/src/Serializer/Normalizer/DocumentNormalizer.php @@ -0,0 +1,109 @@ +documentsStorage = $documentsStorage; + $this->embedFinderFactory = $embedFinderFactory; + } + + /** + * @param mixed $object + * @param string|null $format + * @param array $context + * @return array|\ArrayObject|bool|float|int|string|null + * @throws \Symfony\Component\Serializer\Exception\ExceptionInterface + */ + public function normalize($object, $format = null, array $context = []) + { + $data = $this->decorated->normalize($object, $format, $context); + if ( + $object instanceof Document && + is_array($data) + ) { + /** @var array $serializationGroups */ + $serializationGroups = isset($context['groups']) && is_array($context['groups']) ? $context['groups'] : []; + $data['type'] = $object->getShortType(); + + if ( + !$object->isPrivate() && + !$object->isProcessable() + ) { + $mountPath = $object->getMountPath(); + if (null !== $mountPath) { + $data['publicUrl'] = $this->documentsStorage->publicUrl($mountPath); + } + } + + if ( + \in_array('document_folders_all', $serializationGroups, true) + ) { + $data['folders'] = $object->getFolders() + ->map(function (FolderInterface $folder) use ($format, $context) { + return $this->decorated->normalize($folder, $format, $context); + }) + ->getValues() + ; + } elseif ( + \in_array('document_folders', $serializationGroups, true) + ) { + $data['folders'] = $object->getFolders()->filter(function (FolderInterface $folder) { + return $folder->getVisible(); + })->map(function (FolderInterface $folder) use ($format, $context) { + return $this->decorated->normalize($folder, $format, $context); + })->getValues(); + } + + if ( + $object->getEmbedPlatform() && + $object->getEmbedId() + ) { + $embedFinder = $this->embedFinderFactory->createForPlatform( + $object->getEmbedPlatform(), + $object->getEmbedId() + ); + if (null !== $embedFinder) { + $data['embedUrl'] = $embedFinder->getSource(); + } + } + + if (isset($context['translation']) && $context['translation'] instanceof TranslationInterface) { + $translatedData = $object->getDocumentTranslationsByTranslation($context['translation'])->first() ?: null; + if ($translatedData instanceof DocumentTranslation) { + $data['name'] = $translatedData->getName(); + $data['description'] = $translatedData->getDescription(); + $data['copyright'] = $translatedData->getCopyright(); + $data['alt'] = !empty($translatedData->getName()) ? $translatedData->getName() : $object->getFilename(); + $data['externalUrl'] = $translatedData->getExternalUrl(); + } + } + } + return $data; + } +} diff --git a/lib/RoadizCoreBundle/src/Serializer/Normalizer/DocumentSourcesNormalizer.php b/lib/RoadizCoreBundle/src/Serializer/Normalizer/DocumentSourcesNormalizer.php new file mode 100644 index 00000000..e61ef468 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Serializer/Normalizer/DocumentSourcesNormalizer.php @@ -0,0 +1,65 @@ +documentFinder = $documentFinder; + } + + /** + * @param mixed $object + * @param string|null $format + * @param array $context + * @return array|\ArrayObject|bool|float|int|mixed|string|null + * @throws \Symfony\Component\Serializer\Exception\ExceptionInterface + */ + public function normalize($object, $format = null, array $context = []) + { + $data = $this->decorated->normalize($object, $format, $context); + if ($object instanceof Document && is_array($data)) { + /** @var array $serializationGroups */ + $serializationGroups = isset($context['groups']) && is_array($context['groups']) ? $context['groups'] : []; + + if (\in_array('document_display_sources', $serializationGroups, true)) { + /* + * Reduce serialization group to avoid normalization loop. + */ + $sourcesContext = $context; + $sourcesContext['groups'] = ['document_display']; + + if ($object->isLocal() && $object->isVideo()) { + $data['altSources'] = []; + foreach ($this->documentFinder->findVideosWithFilename($object->getRelativePath()) as $document) { + if ($document->getRelativePath() !== $object->getRelativePath()) { + $data['altSources'][] = $this->decorated->normalize($document, $format, $sourcesContext); + } + } + } elseif ($object->isLocal() && $object->isAudio()) { + $data['altSources'] = []; + foreach ($this->documentFinder->findAudiosWithFilename($object->getRelativePath()) as $document) { + if ($document->getRelativePath() !== $object->getRelativePath()) { + $data['altSources'][] = $this->decorated->normalize($document, $format, $sourcesContext); + } + } + } + } + } + return $data; + } +} diff --git a/lib/RoadizCoreBundle/src/Serializer/Normalizer/FolderNormalizer.php b/lib/RoadizCoreBundle/src/Serializer/Normalizer/FolderNormalizer.php new file mode 100644 index 00000000..b50a7642 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Serializer/Normalizer/FolderNormalizer.php @@ -0,0 +1,36 @@ +decorated->normalize($object, $format, $context); + if ($object instanceof Folder && is_array($data)) { + if (isset($context['translation']) && $context['translation'] instanceof TranslationInterface) { + $translatedData = $object->getTranslatedFoldersByTranslation($context['translation'])->first() ?: null; + if ($translatedData instanceof FolderTranslation) { + $data['name'] = $translatedData->getName(); + } + } + } + return $data; + } +} diff --git a/lib/RoadizCoreBundle/src/Serializer/Normalizer/NodesSourcesPathNormalizer.php b/lib/RoadizCoreBundle/src/Serializer/Normalizer/NodesSourcesPathNormalizer.php new file mode 100644 index 00000000..e6c63040 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Serializer/Normalizer/NodesSourcesPathNormalizer.php @@ -0,0 +1,41 @@ +decorated->normalize($object, $format, $context); + if ( + $object instanceof NodesSources && + $object->isReachable() && + \is_array($data) && + !isset($data['url']) && + isset($context['groups']) && + \in_array('urls', $context['groups'], true) + ) { + $data['url'] = $this->urlGenerator->generate( + RouteObjectInterface::OBJECT_BASED_ROUTE_NAME, + [ + RouteObjectInterface::ROUTE_OBJECT => $object + ], + UrlGeneratorInterface::ABSOLUTE_PATH + ); + } + return $data; + } +} diff --git a/lib/RoadizCoreBundle/src/Serializer/Normalizer/RealmSerializationGroupNormalizer.php b/lib/RoadizCoreBundle/src/Serializer/Normalizer/RealmSerializationGroupNormalizer.php new file mode 100644 index 00000000..f51583b7 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Serializer/Normalizer/RealmSerializationGroupNormalizer.php @@ -0,0 +1,77 @@ +security = $security; + $this->managerRegistry = $managerRegistry; + } + + /** + * @inheritDoc + */ + public function supportsNormalization($data, string $format = null, array $context = []): bool + { + // Make sure we're not called twice + if (isset($context[self::ALREADY_CALLED])) { + return false; + } + + return $data instanceof NodesSources; + } + + /** + * @inheritDoc + * @return array|string|int|float|bool|\ArrayObject|null + */ + public function normalize($object, string $format = null, array $context = []) + { + $realms = $this->getAuthorizedRealmsForObject($object); + + foreach ($realms as $realm) { + if (!empty($realm->getSerializationGroup())) { + $context['groups'][] = $realm->getSerializationGroup(); + } + } + + $context[self::ALREADY_CALLED] = true; + + return $this->normalizer->normalize($object, $format, $context); + } + + /** + * @return Realm[] + */ + private function getAuthorizedRealmsForObject(NodesSources $object): array + { + $realms = $this->managerRegistry->getRepository(Realm::class)->findByNode($object->getNode()); + + return array_filter($realms, function (Realm $realm) { + return $this->security->isGranted(RealmVoter::READ, $realm); + }); + } +} diff --git a/lib/RoadizCoreBundle/src/Serializer/Normalizer/TagNormalizer.php b/lib/RoadizCoreBundle/src/Serializer/Normalizer/TagNormalizer.php new file mode 100644 index 00000000..dfb2af53 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Serializer/Normalizer/TagNormalizer.php @@ -0,0 +1,45 @@ +decorated->normalize($object, $format, $context); + if ($object instanceof Tag && is_array($data)) { + $data['slug'] = $object->getTagName(); + + if (isset($context['translation']) && $context['translation'] instanceof TranslationInterface) { + $documentsContext = $context; + $documentsContext['groups'] = ['document_display']; + $translatedData = $object->getTranslatedTagsByTranslation($context['translation'])->first() ?: null; + if ($translatedData instanceof TagTranslation) { + $data['name'] = $translatedData->getName(); + $data['description'] = $translatedData->getDescription(); + $data['documents'] = array_map(function (DocumentInterface $document) use ($format, $documentsContext) { + return $this->decorated->normalize($document, $format, $documentsContext); + }, $translatedData->getDocuments()); + } + } + } + return $data; + } +} diff --git a/lib/RoadizCoreBundle/src/Serializer/Normalizer/TranslationAwareNormalizer.php b/lib/RoadizCoreBundle/src/Serializer/Normalizer/TranslationAwareNormalizer.php new file mode 100644 index 00000000..a0441cc9 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Serializer/Normalizer/TranslationAwareNormalizer.php @@ -0,0 +1,108 @@ +requestStack = $requestStack; + $this->managerRegistry = $managerRegistry; + $this->previewResolver = $previewResolver; + } + + /** + * @param mixed $object + * @param string|null $format + * @param array $context + * @return array|\ArrayObject|bool|float|int|string|null + * @throws \Symfony\Component\Serializer\Exception\ExceptionInterface + */ + public function normalize($object, $format = null, array $context = []) + { + if ($object instanceof WebResponseInterface) { + $item = $object->getItem(); + if ($item instanceof NodesSources) { + $context['translation'] = $item->getTranslation(); + } elseif (method_exists($item, 'getLocale') && is_string($item->getLocale())) { + $context['translation'] = $this->getTranslationFromLocale($item->getLocale()); + } + } elseif ($object instanceof NodesSources) { + $context['translation'] = $object->getTranslation(); + } elseif (!isset($context['translation']) || !($context['translation'] instanceof TranslationInterface)) { + $translation = $this->getTranslationFromRequest(); + if (null !== $translation) { + $context['translation'] = $translation; + } + } + + $context[self::ALREADY_CALLED] = true; + + return $this->normalizer->normalize($object, $format, $context); + } + + private function getTranslationFromLocale(string $locale): ?TranslationInterface + { + /** @var TranslationRepository $repository */ + $repository = $this->managerRegistry + ->getRepository(TranslationInterface::class); + + if ($this->previewResolver->isPreview()) { + return $repository->findOneByLocaleOrOverrideLocale($locale); + } else { + return $repository->findOneAvailableByLocaleOrOverrideLocale($locale); + } + } + + private function getTranslationFromRequest(): ?TranslationInterface + { + $request = $this->requestStack->getMainRequest(); + if ( + null !== $request && + null !== $translation = $this->getTranslationFromLocale( + $request->query->get('_locale', $request->getLocale()) + ) + ) { + return $translation; + } + + return $this->managerRegistry + ->getRepository(Translation::class) + ->findDefault(); + } + + public function supportsNormalization($data, $format = null, array $context = []): bool + { + // Make sure we're not called twice + if (isset($context[self::ALREADY_CALLED])) { + return false; + } + + return true; + } +} diff --git a/lib/RoadizCoreBundle/src/Serializer/ObjectConstructor/AbstractTypedObjectConstructor.php b/lib/RoadizCoreBundle/src/Serializer/ObjectConstructor/AbstractTypedObjectConstructor.php new file mode 100644 index 00000000..9fb1f1ea --- /dev/null +++ b/lib/RoadizCoreBundle/src/Serializer/ObjectConstructor/AbstractTypedObjectConstructor.php @@ -0,0 +1,100 @@ +entityManager = $entityManager; + $this->fallbackConstructor = $fallbackConstructor; + } + + /** + * @param mixed $data + * @param DeserializationContext $context + * + * @return object|null + */ + abstract protected function findObject($data, DeserializationContext $context): ?object; + + /** + * @param object $object + * @param array $data + */ + abstract protected function fillIdentifier(object $object, array $data): void; + + /** + * @return bool + */ + protected function canBeFlushed(): bool + { + return true; + } + /** + * @inheritDoc + */ + public function construct( + DeserializationVisitorInterface $visitor, + ClassMetadata $metadata, + $data, + array $type, + DeserializationContext $context + ): ?object { + // Entity update, load it from database + $object = $this->findObject($data, $context); + + if ( + null !== $object && + $context->hasAttribute(static::EXCEPTION_ON_EXISTING) && + true === $context->getAttribute(static::EXCEPTION_ON_EXISTING) + ) { + throw new EntityAlreadyExistsException('Object already exists in database.'); + } + + if (null === $object) { + $object = $this->fallbackConstructor->construct($visitor, $metadata, $data, $type, $context); + if ( + $context->hasAttribute(static::PERSIST_NEW_OBJECTS) && + true === $context->getAttribute(static::PERSIST_NEW_OBJECTS) + ) { + $this->entityManager->persist($object); + } + + if ($this->canBeFlushed()) { + /* + * If we need to fetch related entities, we can flush light objects with + * at least their identifier key filled. + */ + $this->fillIdentifier($object, $data); + + if ( + $context->hasAttribute(static::FLUSH_NEW_OBJECTS) && + true === $context->getAttribute(static::FLUSH_NEW_OBJECTS) + ) { + $this->entityManager->flush(); + } + } + } + + $this->entityManager->initializeObject($object); + + return $object; + } +} diff --git a/lib/RoadizCoreBundle/src/Serializer/ObjectConstructor/ChainDoctrineObjectConstructor.php b/lib/RoadizCoreBundle/src/Serializer/ObjectConstructor/ChainDoctrineObjectConstructor.php new file mode 100644 index 00000000..5e3fc28a --- /dev/null +++ b/lib/RoadizCoreBundle/src/Serializer/ObjectConstructor/ChainDoctrineObjectConstructor.php @@ -0,0 +1,131 @@ + + */ + protected array $typedObjectConstructors; + protected ObjectConstructorInterface $fallbackConstructor; + + /** + * @param ObjectManager|null $entityManager + * @param ObjectConstructorInterface $fallbackConstructor + * @param array $typedObjectConstructors + */ + public function __construct( + ?ObjectManager $entityManager, + ObjectConstructorInterface $fallbackConstructor, + array $typedObjectConstructors + ) { + $this->entityManager = $entityManager; + $this->typedObjectConstructors = $typedObjectConstructors; + $this->fallbackConstructor = $fallbackConstructor; + } + + /** + * @param DeserializationVisitorInterface $visitor + * @param ClassMetadata $metadata + * @param PersistableInterface|array $data + * @param array $type + * @param DeserializationContext $context + * @return object|null + */ + public function construct( + DeserializationVisitorInterface $visitor, + ClassMetadata $metadata, + $data, + array $type, + DeserializationContext $context + ): ?object { + if (null === $this->entityManager) { + // No ObjectManager found, proceed with normal deserialization + return $this->fallbackConstructor->construct($visitor, $metadata, $data, $type, $context); + } + + // Locate possible ClassMetadata + $classMetadataFactory = $this->entityManager->getMetadataFactory(); + try { + $doctrineMetadata = $classMetadataFactory->getMetadataFor($metadata->name); + if ($doctrineMetadata->getName() !== $metadata->name) { + /* + * Doctrine resolveTargetEntity has found an alternative class + */ + $metadata = new ClassMetadata($doctrineMetadata->getName()); + } + } catch (\Doctrine\ORM\Mapping\MappingException $e) { + // Object class is not a valid doctrine entity + } + + if ($classMetadataFactory->isTransient($metadata->name)) { + // No ClassMetadata found, proceed with normal deserialization + return $this->fallbackConstructor->construct($visitor, $metadata, $data, $type, $context); + } + + // Managed entity, check for proxy load + if (!\is_array($data)) { + // Single identifier, load proxy + return $this->entityManager->getReference($metadata->name, $data); + } + + /** @var TypedObjectConstructorInterface $typedObjectConstructor */ + foreach ($this->typedObjectConstructors as $typedObjectConstructor) { + if ($typedObjectConstructor->supports($metadata->name, $data)) { + return $typedObjectConstructor->construct( + $visitor, + $metadata, + $data, + $type, + $context + ); + } + } + + // PHPStan need to explicit classname + /** @var class-string $className */ + $className = $metadata->name; + + // Fallback to default constructor if missing identifier(s) + $classMetadata = $this->entityManager->getClassMetadata($className); + $identifierList = []; + + foreach ($classMetadata->getIdentifierFieldNames() as $name) { + if ( + isset($metadata->propertyMetadata[$name]) && + isset($metadata->propertyMetadata[$name]->serializedName) + ) { + $dataName = $metadata->propertyMetadata[$name]->serializedName; + } else { + $dataName = $name; + } + + if (!array_key_exists($dataName, $data)) { + return $this->fallbackConstructor->construct($visitor, $metadata, $data, $type, $context); + } + $identifierList[$name] = $data[$dataName]; + } + + // Entity update, load it from database + $object = $this->entityManager->find($className, $identifierList); + + if (null === $object) { + return $this->fallbackConstructor->construct($visitor, $metadata, $data, $type, $context); + } + + $this->entityManager->initializeObject($object); + + return $object; + } +} diff --git a/lib/RoadizCoreBundle/src/Serializer/ObjectConstructor/GroupObjectConstructor.php b/lib/RoadizCoreBundle/src/Serializer/ObjectConstructor/GroupObjectConstructor.php new file mode 100644 index 00000000..8d0fbf1e --- /dev/null +++ b/lib/RoadizCoreBundle/src/Serializer/ObjectConstructor/GroupObjectConstructor.php @@ -0,0 +1,44 @@ +entityManager->getRepository(Group::class)->findOneByName($data['name']); + } + + protected function fillIdentifier(object $object, array $data): void + { + trigger_error('Cannot call fillIdentifier on Group', E_USER_WARNING); + } + + /** + * @return bool + */ + protected function canBeFlushed(): bool + { + return false; + } +} diff --git a/lib/RoadizCoreBundle/src/Serializer/ObjectConstructor/NodeObjectConstructor.php b/lib/RoadizCoreBundle/src/Serializer/ObjectConstructor/NodeObjectConstructor.php new file mode 100644 index 00000000..4a9412dc --- /dev/null +++ b/lib/RoadizCoreBundle/src/Serializer/ObjectConstructor/NodeObjectConstructor.php @@ -0,0 +1,43 @@ +entityManager + ->getRepository(Node::class) + ->setDisplayingAllNodesStatuses(true); + return $nodeRepository->findOneByNodeName($data['nodeName'] ?? $data['node_name']); + } + + protected function fillIdentifier(object $object, array $data): void + { + if ($object instanceof Node) { + $object->setNodeName($data['nodeName'] ?? $data['node_name']); + } + } +} diff --git a/lib/RoadizCoreBundle/src/Serializer/ObjectConstructor/NodeTypeFieldObjectConstructor.php b/lib/RoadizCoreBundle/src/Serializer/ObjectConstructor/NodeTypeFieldObjectConstructor.php new file mode 100644 index 00000000..71fddc76 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Serializer/ObjectConstructor/NodeTypeFieldObjectConstructor.php @@ -0,0 +1,64 @@ +entityManager + ->getRepository(NodeType::class) + ->findOneByName($data['nodeTypeName'] ?? $data['node_type_name']); + + if (null === $nodeType) { + /* + * Do not look for existing fields if node-type does not exist either. + */ + return null; + } + return $this->entityManager + ->getRepository(NodeTypeField::class) + ->findOneBy([ + 'name' => $data['name'], + 'nodeType' => $nodeType, + ]); + } + + protected function fillIdentifier(object $object, array $data): void + { + trigger_error('Cannot call fillIdentifier on NodeTypeField', E_USER_WARNING); + } + + /** + * @return bool + */ + protected function canBeFlushed(): bool + { + return false; + } +} diff --git a/lib/RoadizCoreBundle/src/Serializer/ObjectConstructor/NodeTypeObjectConstructor.php b/lib/RoadizCoreBundle/src/Serializer/ObjectConstructor/NodeTypeObjectConstructor.php new file mode 100644 index 00000000..71d96815 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Serializer/ObjectConstructor/NodeTypeObjectConstructor.php @@ -0,0 +1,40 @@ +entityManager + ->getRepository(NodeType::class) + ->findOneByName($data['name']); + } + + protected function fillIdentifier(object $object, array $data): void + { + if ($object instanceof NodeType) { + $object->setName($data['name']); + } + } +} diff --git a/lib/RoadizCoreBundle/src/Serializer/ObjectConstructor/ObjectConstructor.php b/lib/RoadizCoreBundle/src/Serializer/ObjectConstructor/ObjectConstructor.php new file mode 100644 index 00000000..b56536c5 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Serializer/ObjectConstructor/ObjectConstructor.php @@ -0,0 +1,24 @@ +name; + return new $className(); + } +} diff --git a/lib/RoadizCoreBundle/src/Serializer/ObjectConstructor/RoleObjectConstructor.php b/lib/RoadizCoreBundle/src/Serializer/ObjectConstructor/RoleObjectConstructor.php new file mode 100644 index 00000000..492348be --- /dev/null +++ b/lib/RoadizCoreBundle/src/Serializer/ObjectConstructor/RoleObjectConstructor.php @@ -0,0 +1,40 @@ +entityManager + ->getRepository(Role::class) + ->findOneByName($data['name']); + } + + protected function fillIdentifier(object $object, array $data): void + { + if ($object instanceof Role) { + $object->setRole($data['name']); + } + } +} diff --git a/lib/RoadizCoreBundle/src/Serializer/ObjectConstructor/SettingGroupObjectConstructor.php b/lib/RoadizCoreBundle/src/Serializer/ObjectConstructor/SettingGroupObjectConstructor.php new file mode 100644 index 00000000..0b6a8adb --- /dev/null +++ b/lib/RoadizCoreBundle/src/Serializer/ObjectConstructor/SettingGroupObjectConstructor.php @@ -0,0 +1,40 @@ +entityManager + ->getRepository(SettingGroup::class) + ->findOneByName($data['name']); + } + + protected function fillIdentifier(object $object, array $data): void + { + if ($object instanceof SettingGroup) { + $object->setName($data['name']); + } + } +} diff --git a/lib/RoadizCoreBundle/src/Serializer/ObjectConstructor/SettingObjectConstructor.php b/lib/RoadizCoreBundle/src/Serializer/ObjectConstructor/SettingObjectConstructor.php new file mode 100644 index 00000000..7db87eb5 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Serializer/ObjectConstructor/SettingObjectConstructor.php @@ -0,0 +1,38 @@ +entityManager->getRepository(Setting::class)->findOneByName($data['name']); + } + + protected function fillIdentifier(object $object, array $data): void + { + if ($object instanceof Setting) { + $object->setName($data['name']); + } + } +} diff --git a/lib/RoadizCoreBundle/src/Serializer/ObjectConstructor/TagObjectConstructor.php b/lib/RoadizCoreBundle/src/Serializer/ObjectConstructor/TagObjectConstructor.php new file mode 100644 index 00000000..126ed722 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Serializer/ObjectConstructor/TagObjectConstructor.php @@ -0,0 +1,56 @@ +entityManager + ->getRepository(Tag::class) + ->findOneByTagName($data['tagName'] ?? $data['tag_name']); + + if ( + null !== $tag && + $context->hasAttribute(static::EXCEPTION_ON_EXISTING_TAG) && + true === $context->hasAttribute(static::EXCEPTION_ON_EXISTING_TAG) + ) { + throw new EntityAlreadyExistsException('Tag already exists in database.'); + } + + return $tag; + } + + protected function fillIdentifier(object $object, array $data): void + { + if ($object instanceof Tag) { + $object->setTagName($data['tagName'] ?? $data['tag_name']); + } + } +} diff --git a/lib/RoadizCoreBundle/src/Serializer/ObjectConstructor/TranslationObjectConstructor.php b/lib/RoadizCoreBundle/src/Serializer/ObjectConstructor/TranslationObjectConstructor.php new file mode 100644 index 00000000..40309d06 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Serializer/ObjectConstructor/TranslationObjectConstructor.php @@ -0,0 +1,42 @@ +entityManager + ->getRepository(Translation::class) + ->findOneByLocale($data['locale']); + } + + protected function fillIdentifier(object $object, array $data): void + { + if ($object instanceof Translation) { + $object->setLocale($data['locale']); + $object->setName($data['locale']); + } + } +} diff --git a/lib/RoadizCoreBundle/src/Serializer/ObjectConstructor/TypedObjectConstructorInterface.php b/lib/RoadizCoreBundle/src/Serializer/ObjectConstructor/TypedObjectConstructorInterface.php new file mode 100644 index 00000000..6e8460b3 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Serializer/ObjectConstructor/TypedObjectConstructorInterface.php @@ -0,0 +1,22 @@ +decorated = $decorated; + $this->managerRegistry = $managerRegistry; + $this->previewResolver = $previewResolver; + } + /** + * @inheritDoc + */ + public function createFromRequest(Request $request, bool $normalization, array $extractedAttributes = null): array + { + $context = $this->decorated->createFromRequest($request, $normalization, $extractedAttributes); + + if (!isset($context['translation']) || !($context['translation'] instanceof TranslationInterface)) { + /** @var TranslationRepository $repository */ + $repository = $this->managerRegistry + ->getRepository(TranslationInterface::class); + + if ($this->previewResolver->isPreview()) { + $translation = $repository->findOneByLocaleOrOverrideLocale( + $request->query->get('_locale', $request->getLocale()) + ); + } else { + $translation = $repository->findOneAvailableByLocaleOrOverrideLocale( + $request->query->get('_locale', $request->getLocale()) + ); + } + + if ($translation instanceof TranslationInterface) { + $context['translation'] = $translation; + } + } + return $context; + } +} diff --git a/lib/RoadizCoreBundle/src/Tag/TagFactory.php b/lib/RoadizCoreBundle/src/Tag/TagFactory.php new file mode 100644 index 00000000..f5bd04e4 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Tag/TagFactory.php @@ -0,0 +1,79 @@ +managerRegistry = $managerRegistry; + } + + /** + * @param string $name + * @param TranslationInterface|null $translation + * @param Tag|null $parent + * @param int|float $latestPosition + * + * @return Tag + */ + public function create(string $name, ?TranslationInterface $translation = null, ?Tag $parent = null, $latestPosition = 0): Tag + { + $name = strip_tags(trim($name)); + $tagName = StringHandler::slugify($name); + if (empty($tagName)) { + throw new \RuntimeException('Tag name is empty.'); + } + if (mb_strlen($tagName) > 250) { + throw new \InvalidArgumentException(sprintf('Tag name "%s" is too long.', $tagName)); + } + + /** @var TagRepository $repository */ + $repository = $this->managerRegistry->getRepository(Tag::class); + + if (null !== $tag = $repository->findOneByTagName($tagName)) { + return $tag; + } + + if ($translation === null) { + $translation = $this->managerRegistry->getRepository(TranslationInterface::class)->findDefault(); + } + + if ($latestPosition <= 0) { + /* + * Get latest position to add tags after. + * Warning: need to flush between calls + */ + $latestPosition = $repository->findLatestPositionInParent($parent); + } + + $manager = $this->managerRegistry->getManagerForClass(Tag::class); + + $tag = new Tag(); + $tag->setTagName($name); + $tag->setParent($parent); + $tag->setPosition(++$latestPosition); + $tag->setVisible(true); + $manager->persist($tag); + + $translatedTag = new TagTranslation($tag, $translation); + $translatedTag->setName($name); + $manager->persist($translatedTag); + + return $tag; + } +} diff --git a/lib/RoadizCoreBundle/src/Traits/LoginRequestTrait.php b/lib/RoadizCoreBundle/src/Traits/LoginRequestTrait.php new file mode 100644 index 00000000..d804a62d --- /dev/null +++ b/lib/RoadizCoreBundle/src/Traits/LoginRequestTrait.php @@ -0,0 +1,72 @@ +get('email')->getData(); + /** @var User $user */ + $user = $entityManager->getRepository(User::class)->findOneByEmail($email); + + if (null !== $user) { + if (!$user->isPasswordRequestNonExpired(User::CONFIRMATION_TTL)) { + try { + $tokenGenerator = new TokenGenerator($logger); + $user->setPasswordRequestedAt(new \DateTime()); + $user->setConfirmationToken($tokenGenerator->generateToken()); + $entityManager->flush(); + $userViewer = $this->getUserViewer(); + $userViewer->setUser($user); + $userViewer->sendPasswordResetLink($resetRoute); + return true; + } catch (\Exception $e) { + $user->setPasswordRequestedAt(null); + $user->setConfirmationToken(null); + $entityManager->flush(); + $logger->error($e->getMessage()); + $form->addError(new FormError($e->getMessage())); + } + } else { + $form->addError(new FormError('a.confirmation.email.has.already.be.sent')); + } + } + + return false; + } +} diff --git a/lib/RoadizCoreBundle/src/Traits/LoginResetTrait.php b/lib/RoadizCoreBundle/src/Traits/LoginResetTrait.php new file mode 100644 index 00000000..2788cb3a --- /dev/null +++ b/lib/RoadizCoreBundle/src/Traits/LoginResetTrait.php @@ -0,0 +1,51 @@ +getRepository(User::class)->findOneByConfirmationToken($token); + } + + /** + * @param FormInterface $form + * @param User $user + * @param ObjectManager $entityManager + * + * @return bool + */ + public function updateUserPassword(FormInterface $form, User $user, ObjectManager $entityManager) + { + $user->setConfirmationToken(null); + $user->setPasswordRequestedAt(null); + $user->setPlainPassword($form->get('plainPassword')->getData()); + /* + * If user was forced to update its credentials, + * we remove expiration. + */ + if (!$user->isCredentialsNonExpired()) { + if ($user->getCredentialsExpired() === true) { + $user->setCredentialsExpired(false); + } + if (null !== $user->getCredentialsExpiresAt()) { + $user->setCredentialsExpiresAt(null); + } + } + $entityManager->flush(); + + return true; + } +} diff --git a/lib/RoadizCoreBundle/src/Translation/TranslationViewer.php b/lib/RoadizCoreBundle/src/Translation/TranslationViewer.php new file mode 100644 index 00000000..af42958e --- /dev/null +++ b/lib/RoadizCoreBundle/src/Translation/TranslationViewer.php @@ -0,0 +1,250 @@ +settingsBag = $settingsBag; + $this->router = $router; + $this->previewResolver = $previewResolver; + $this->managerRegistry = $managerRegistry; + } + + /** + * @return TranslationRepository + */ + public function getRepository(): TranslationRepository + { + return $this->managerRegistry->getRepository(Translation::class); + } + + /** + * Return available page translation information. + * + * Be careful, for static routes Roadiz will generate a localized + * route identifier suffixed with "Locale" text. In case of "force_locale" + * setting to true, Roadiz will always use suffixed route. + * + * ## example return value + * + * array (size=3) + * 'en' => + * array (size=4) + * 'name' => string 'newsPage' + * 'url' => string 'http://localhost/news/test' + * 'locale' => string 'en' + * 'active' => boolean false + * 'translation' => string 'English' + * 'fr' => + * array (size=4) + * 'name' => string 'newsPageLocale' + * 'url' => string 'http://localhost/fr/news/test' + * 'locale' => string 'fr' + * 'active' => boolean true + * 'translation' => string 'French' + * 'es' => + * array (size=4) + * 'name' => string 'newsPageLocale' + * 'url' => string 'http://localhost/es/news/test' + * 'locale' => string 'es' + * 'active' => boolean false + * 'translation' => string 'Spanish' + * + * @param Request $request + * @param bool $absolute Generate absolute url or relative paths + * + * @return array + * @throws ORMException + */ + public function getTranslationMenuAssignation(Request $request, bool $absolute = false): array + { + $attr = $request->attributes->all(); + $query = $request->query->all(); + $name = ''; + $forceLocale = (bool) $this->settingsBag->get('force_locale'); + $useStaticRouting = !empty($attr['_route']) && + is_string($attr['_route']) && + $attr['_route'] !== RouteObjectInterface::OBJECT_BASED_ROUTE_NAME; + + /* + * Fix absolute boolean to Int constant. + */ + $absolute = $absolute ? UrlGeneratorInterface::ABSOLUTE_URL : UrlGeneratorInterface::ABSOLUTE_PATH; + + if (key_exists('node', $attr) && $attr['node'] instanceof Node) { + $node = $attr["node"]; + $this->managerRegistry->getManagerForClass(Node::class)->refresh($node); + } else { + $node = null; + } + /* + * If using a static route (routes.yml)… + */ + if ($useStaticRouting) { + $translations = $this->getRepository()->findAllAvailable(); + /* + * Search for a route without Locale suffix + */ + $baseRoute = RouteHandler::getBaseRoute($attr["_route"]); + if (null !== $this->router->getRouteCollection()->get($baseRoute)) { + $attr["_route"] = $baseRoute; + } + } elseif (null !== $node) { + /* + * If using dynamic routing… + */ + if ($this->previewResolver->isPreview()) { + $translations = $this->getRepository()->findAvailableTranslationsForNode($node); + } else { + $translations = $this->getRepository()->findStrictlyAvailableTranslationsForNode($node); + } + $name = "node"; + } else { + return []; + } + + $return = []; + + foreach ($translations as $translation) { + $url = null; + /* + * Remove existing _locale in query string + */ + if (key_exists('_locale', $query)) { + unset($query["_locale"]); + } + /* + * Remove existing page parameter in query string + * if listing is different between 2 languages, maybe + * page 2 or 3 does not exist in language B but exists in + * language A + */ + if (key_exists('page', $query)) { + unset($query['page']); + } + + if ($useStaticRouting) { + $name = $attr['_route']; + /* + * Use suffixed route if locales are forced or + * if it’s not default translation. + */ + if (true === $forceLocale || !$translation->isDefaultTranslation()) { + /* + * Search for a Locale suffixed route + */ + if (null !== $this->router->getRouteCollection()->get($attr['_route'] . "Locale")) { + $name = $attr['_route'] . 'Locale'; + } + + $attr['_route_params']['_locale'] = $translation->getPreferredLocale(); + } else { + if (key_exists('_locale', $attr['_route_params'])) { + unset($attr['_route_params']['_locale']); + } + } + + /* + * Remove existing page parameter in route parameters + * if listing is different between 2 languages, maybe + * page 2 or 3 does not exist in language B but exists in + * language A + */ + if (key_exists('page', $attr['_route_params'])) { + unset($attr['_route_params']['page']); + } + + if (is_string($name)) { + $url = $this->router->generate( + $name, + array_merge($attr['_route_params'], $query), + $absolute + ); + } else { + $url = $this->router->generate( + RouteObjectInterface::OBJECT_BASED_ROUTE_NAME, + array_merge($attr['_route_params'], $query, [ + RouteObjectInterface::ROUTE_OBJECT => $name + ]), + $absolute + ); + } + } elseif ($node) { + $nodesSources = $node->getNodeSourcesByTranslation($translation)->first() ?: null; + if ($nodesSources instanceof NodesSources) { + $url = $this->router->generate( + RouteObjectInterface::OBJECT_BASED_ROUTE_NAME, + array_merge($query, [ + RouteObjectInterface::ROUTE_OBJECT => $nodesSources + ]), + $absolute + ); + } + } + + if (null !== $url) { + $return[$translation->getPreferredLocale()] = [ + 'name' => $name, + 'url' => $url, + 'locale' => $translation->getPreferredLocale(), + 'active' => $this->translation->getPreferredLocale() === $translation->getPreferredLocale(), + 'translation' => $translation->getName(), + ]; + } + } + return $return; + } + + /** + * @return TranslationInterface|null + */ + public function getTranslation(): ?TranslationInterface + { + return $this->translation; + } + + /** + * @param TranslationInterface|null $translation + * @return TranslationViewer + */ + public function setTranslation(?TranslationInterface $translation) + { + $this->translation = $translation; + return $this; + } +} diff --git a/lib/RoadizCoreBundle/src/TwigExtension/AttributesExtension.php b/lib/RoadizCoreBundle/src/TwigExtension/AttributesExtension.php new file mode 100644 index 00000000..890cfa49 --- /dev/null +++ b/lib/RoadizCoreBundle/src/TwigExtension/AttributesExtension.php @@ -0,0 +1,266 @@ +entityManager = $entityManager; + } + + public function getFunctions(): array + { + return [ + new TwigFunction('get_attributes', [$this, 'getAttributeValues']), + new TwigFunction('node_source_attributes', [$this, 'getNodeSourceAttributeValues']), + new TwigFunction('node_source_grouped_attributes', [$this, 'getNodeSourceGroupedAttributeValues']), + ]; + } + + public function getFilters(): array + { + return [ + new TwigFilter('attributes', [$this, 'getNodeSourceAttributeValues']), + new TwigFilter('grouped_attributes', [$this, 'getNodeSourceGroupedAttributeValues']), + new TwigFilter('attribute_label', [$this, 'getAttributeLabelOrCode']), + new TwigFilter('attribute_group_label', [$this, 'getAttributeGroupLabelOrCode']), + ]; + } + + public function getTests(): array + { + return [ + new TwigTest('datetime', [$this, 'isDateTime']), + new TwigTest('date', [$this, 'isDate']), + new TwigTest('country', [$this, 'isCountry']), + new TwigTest('boolean', [$this, 'isBoolean']), + new TwigTest('choice', [$this, 'isEnum']), + new TwigTest('enum', [$this, 'isEnum']), + new TwigTest('number', [$this, 'isNumber']), + new TwigTest('percent', [$this, 'isPercent']), + ]; + } + + public function isDateTime(AttributeValueTranslationInterface $attributeValueTranslation): bool + { + return $attributeValueTranslation->getAttributeValue()->getAttribute()->isDateTime(); + } + + public function isDate(AttributeValueTranslationInterface $attributeValueTranslation): bool + { + return $attributeValueTranslation->getAttributeValue()->getAttribute()->isDate(); + } + + public function isCountry(AttributeValueTranslationInterface $attributeValueTranslation): bool + { + return $attributeValueTranslation->getAttributeValue()->getAttribute()->isCountry(); + } + + public function isBoolean(AttributeValueTranslationInterface $attributeValueTranslation): bool + { + return $attributeValueTranslation->getAttributeValue()->getAttribute()->isBoolean(); + } + + public function isEnum(AttributeValueTranslationInterface $attributeValueTranslation): bool + { + return $attributeValueTranslation->getAttributeValue()->getAttribute()->isEnum(); + } + + public function isPercent(AttributeValueTranslationInterface $attributeValueTranslation): bool + { + return $attributeValueTranslation->getAttributeValue()->getAttribute()->isPercent(); + } + + public function isNumber(AttributeValueTranslationInterface $attributeValueTranslation): bool + { + return $attributeValueTranslation->getAttributeValue()->getAttribute()->isInteger() || + $attributeValueTranslation->getAttributeValue()->getAttribute()->isDecimal(); + } + + + /** + * @param AttributableInterface|null $attributable + * @param TranslationInterface $translation + * @param bool $hideNotTranslated + * + * @return array + * @throws SyntaxError + */ + public function getAttributeValues( + ?AttributableInterface $attributable, + TranslationInterface $translation, + bool $hideNotTranslated = false + ): array { + if (null === $attributable) { + throw new SyntaxError('Cannot call get_attributes on NULL'); + } + if (!$attributable instanceof AttributableInterface) { + throw new SyntaxError('get_attributes only accepts entities that implement AttributableInterface'); + } + $attributeValueTranslations = []; + + if ($hideNotTranslated) { + $attributeValues = $this->entityManager + ->getRepository(AttributeValue::class) + ->findByAttributableAndTranslation( + $attributable, + $translation + ); + } else { + /* + * Do not filter by translation here as we need to + * fallback attributeValues to defaultTranslation + * if not filled up. + */ + $attributeValues = $this->entityManager + ->getRepository(AttributeValue::class) + ->findByAttributable( + $attributable + ); + } + + /** @var AttributeValueInterface $attributeValue */ + foreach ($attributeValues as $attributeValue) { + $attributeValueTranslation = $attributeValue->getAttributeValueTranslation($translation); + if (null !== $attributeValueTranslation) { + $attributeValueTranslations[] = $attributeValueTranslation; + } elseif (false !== $attributeValue->getAttributeValueTranslations()->first()) { + $attributeValueTranslations[] = $attributeValue->getAttributeValueTranslations()->first(); + } + } + + return $attributeValueTranslations; + } + + /** + * @param NodesSources|null $nodesSources + * @param bool $hideNotTranslated + * + * @return array + * @throws SyntaxError + */ + public function getNodeSourceAttributeValues(?NodesSources $nodesSources, bool $hideNotTranslated = false): array + { + if (null === $nodesSources) { + throw new SyntaxError('Cannot call node_source_attributes on NULL'); + } + return $this->getAttributeValues($nodesSources->getNode(), $nodesSources->getTranslation(), $hideNotTranslated); + } + + /** + * @param NodesSources|null $nodesSources + * @param bool $hideNotTranslated + * + * @return array + * @throws SyntaxError + */ + public function getNodeSourceGroupedAttributeValues(?NodesSources $nodesSources, bool $hideNotTranslated = false): array + { + $groups = [ + INF => [ + 'group' => null, + 'attributeValues' => [] + ] + ]; + $attributeValueTranslations = $this->getNodeSourceAttributeValues($nodesSources, $hideNotTranslated); + /** @var AttributeValueTranslationInterface $attributeValueTranslation */ + foreach ($attributeValueTranslations as $attributeValueTranslation) { + $group = $attributeValueTranslation->getAttributeValue()->getAttribute()->getGroup(); + if (null !== $group) { + if (!isset($groups[$group->getCanonicalName()])) { + $groups[$group->getCanonicalName()] = [ + 'group' => $group, + 'attributeValues' => [] + ]; + } + $groups[$group->getCanonicalName()]['attributeValues'][] = $attributeValueTranslation; + } else { + $groups[INF]['attributeValues'][] = $attributeValueTranslation; + } + } + + return array_filter($groups, function (array $group) { + return count($group['attributeValues']) > 0; + }); + } + + /** + * @param mixed $mixed + * @param TranslationInterface|null $translation + * + * @return string|null + */ + public function getAttributeLabelOrCode($mixed, TranslationInterface $translation = null): ?string + { + if (null === $mixed) { + return null; + } + + if ($mixed instanceof AttributeInterface) { + return $mixed->getLabelOrCode($translation); + } + if ($mixed instanceof AttributeValueInterface) { + return $mixed->getAttribute()->getLabelOrCode($translation); + } + if ($mixed instanceof AttributeValueTranslationInterface) { + if (null === $translation) { + $translation = $mixed->getTranslation(); + } + return $mixed->getAttributeValue()->getAttribute()->getLabelOrCode($translation); + } + + return null; + } + + /** + * @param mixed $mixed + * @param TranslationInterface|null $translation + * @return string|null + */ + public function getAttributeGroupLabelOrCode($mixed, TranslationInterface $translation = null): ?string + { + if (null === $mixed) { + return null; + } + if ($mixed instanceof AttributeGroupInterface) { + return $mixed->getTranslatedName($translation); + } + if ($mixed instanceof AttributeInterface && null !== $mixed->getGroup()) { + return $mixed->getGroup()->getTranslatedName($translation); + } + if ($mixed instanceof AttributeValueInterface && null !== $mixed->getAttribute()->getGroup()) { + return $mixed->getAttribute()->getGroup()->getTranslatedName($translation); + } + if ($mixed instanceof AttributeValueTranslationInterface && null !== $mixed->getAttribute()->getGroup()) { + if (null === $translation) { + $translation = $mixed->getTranslation(); + } + return $mixed->getAttribute()->getGroup()->getTranslatedName($translation); + } + + return null; + } +} diff --git a/lib/RoadizCoreBundle/src/TwigExtension/BlockRenderExtension.php b/lib/RoadizCoreBundle/src/TwigExtension/BlockRenderExtension.php new file mode 100644 index 00000000..e4944307 --- /dev/null +++ b/lib/RoadizCoreBundle/src/TwigExtension/BlockRenderExtension.php @@ -0,0 +1,83 @@ +handler = $handler; + } + + public function getFilters(): array + { + return [ + new TwigFilter('render_block', [$this, 'blockRender'], ['is_safe' => ['html']]), + ]; + } + + /** + * @param NodesSources|null $nodeSource + * @param string $themeName + * @param array $assignation + * + * @return string + * @throws RuntimeError + */ + public function blockRender(NodesSources $nodeSource = null, string $themeName = "DefaultTheme", array $assignation = []) + { + if (null !== $nodeSource) { + if (!empty($themeName)) { + $class = $this->getNodeSourceControllerName($nodeSource, $themeName); + if (class_exists($class) && method_exists($class, 'blockAction')) { + $controllerReference = new ControllerReference($class . '::blockAction', [ + 'source' => $nodeSource, + 'assignation' => $assignation, + ]); + /* + * ignore_errors option MUST BE false in order to catch ForceResponseException + * from Master request render method and redirect users. + */ + return $this->handler->render($controllerReference, 'inline', [ + 'ignore_errors' => false + ]); + } else { + throw new RuntimeError($class . "::blockAction() action does not exist."); + } + } else { + throw new RuntimeError("Invalid name formatting for your theme."); + } + } + throw new RuntimeError("Invalid NodesSources."); + } + + /** + * @param NodesSources $nodeSource + * @param string $themeName + * + * @return string + */ + protected function getNodeSourceControllerName(NodesSources $nodeSource, string $themeName): string + { + return '\\Themes\\' . $themeName . '\\Controllers\\Blocks\\' . + $nodeSource->getNodeTypeName() . 'Controller'; + } +} diff --git a/lib/RoadizCoreBundle/src/TwigExtension/CentralTruncateExtension.php b/lib/RoadizCoreBundle/src/TwigExtension/CentralTruncateExtension.php new file mode 100644 index 00000000..8c3c8851 --- /dev/null +++ b/lib/RoadizCoreBundle/src/TwigExtension/CentralTruncateExtension.php @@ -0,0 +1,50 @@ +length() > $length + $unicodeEllipsis->length()) { + $str1 = $unicode->slice(0, (int)(floor($length / 2) + floor($offset / 2))); + $str2 = $unicode->slice((int)((floor($length / 2) * -1) + floor($offset / 2))); + return $str1 . $ellipsis . $str2; + } + + return $object; + } +} diff --git a/lib/RoadizCoreBundle/src/TwigExtension/DocumentUrlExtension.php b/lib/RoadizCoreBundle/src/TwigExtension/DocumentUrlExtension.php new file mode 100644 index 00000000..869d4d6b --- /dev/null +++ b/lib/RoadizCoreBundle/src/TwigExtension/DocumentUrlExtension.php @@ -0,0 +1,84 @@ +throwExceptions = $throwExceptions; + $this->documentUrlGenerator = $documentUrlGenerator; + } + + /** + * @return array + */ + public function getFilters(): array + { + return [ + new TwigFilter('url', [$this, 'getUrl']), + ]; + } + + /** + * Convert an AbstractEntity to an Url. + * + * Compatible AbstractEntity: + * + * - Document + * + * @param PersistableInterface|null $mixed + * @param array $criteria + * @return string + * @throws RuntimeError + */ + public function getUrl(PersistableInterface $mixed = null, array $criteria = []) + { + if (null === $mixed) { + if ($this->throwExceptions) { + throw new RuntimeError("Twig “url” filter must be used with a not null object"); + } else { + return ""; + } + } + + if ($mixed instanceof Document) { + try { + $absolute = false; + if (isset($criteria['absolute'])) { + $absolute = (bool) $criteria['absolute']; + } + + $this->documentUrlGenerator->setOptions($criteria); + $this->documentUrlGenerator->setDocument($mixed); + return $this->documentUrlGenerator->getUrl($absolute); + } catch (InvalidArgumentException $e) { + throw new RuntimeError($e->getMessage(), -1, null, $e); + } + } + + throw new RuntimeError("Twig “url” filter can be only used with a Document"); + } +} diff --git a/lib/RoadizCoreBundle/src/TwigExtension/HandlerExtension.php b/lib/RoadizCoreBundle/src/TwigExtension/HandlerExtension.php new file mode 100644 index 00000000..186355d6 --- /dev/null +++ b/lib/RoadizCoreBundle/src/TwigExtension/HandlerExtension.php @@ -0,0 +1,54 @@ +handlerFactory = $handlerFactory; + } + + public function getFilters(): array + { + return [ + new TwigFilter('handler', [$this, 'getHandler']), + ]; + } + + /** + * @param mixed $mixed + * @return AbstractHandler|null + * @throws RuntimeError + */ + public function getHandler($mixed) + { + if (null === $mixed) { + return null; + } + + if ($mixed instanceof AbstractEntity) { + try { + return $this->handlerFactory->getHandler($mixed); + } catch (\InvalidArgumentException $exception) { + throw new RuntimeError($exception->getMessage(), -1, null, $exception); + } + } + + throw new RuntimeError('Handler filter only supports AbstractEntity objects.'); + } +} diff --git a/lib/RoadizCoreBundle/src/TwigExtension/JwtExtension.php b/lib/RoadizCoreBundle/src/TwigExtension/JwtExtension.php new file mode 100644 index 00000000..c8d2452b --- /dev/null +++ b/lib/RoadizCoreBundle/src/TwigExtension/JwtExtension.php @@ -0,0 +1,51 @@ +tokenManager = $tokenManager; + $this->logger = $logger; + $this->previewUserProvider = $previewUserProvider; + } + + public function getFunctions(): array + { + return [ + new TwigFunction('createPreviewJwt', [$this, 'createPreviewJwt']), + ]; + } + + public function createPreviewJwt(): ?string + { + try { + return $this->tokenManager->create($this->previewUserProvider->createFromSecurity()); + } catch (AccessDeniedException $exception) { + $this->logger->warning($exception->getMessage()); + return null; + } catch (JWTFailureException $exception) { + $this->logger->warning($exception->getMessage()); + return null; + } + } +} diff --git a/lib/RoadizCoreBundle/src/TwigExtension/NodesSourcesExtension.php b/lib/RoadizCoreBundle/src/TwigExtension/NodesSourcesExtension.php new file mode 100644 index 00000000..59a2b90e --- /dev/null +++ b/lib/RoadizCoreBundle/src/TwigExtension/NodesSourcesExtension.php @@ -0,0 +1,251 @@ +throwExceptions = $throwExceptions; + $this->handlerFactory = $handlerFactory; + $this->nodeTypesBag = $nodeTypesBag; + $this->nodeSourceApi = $nodeSourceApi; + } + + public function getFilters(): array + { + return [ + new TwigFilter('children', [$this, 'getChildren']), + new TwigFilter('next', [$this, 'getNext']), + new TwigFilter('previous', [$this, 'getPrevious']), + new TwigFilter('lastSibling', [$this, 'getLastSibling']), + new TwigFilter('firstSibling', [$this, 'getFirstSibling']), + new TwigFilter('parent', [$this, 'getParent']), + new TwigFilter('parents', [$this, 'getParents']), + new TwigFilter('tags', [$this, 'getTags']), + ]; + } + + public function getTests(): array + { + $tests = []; + + foreach ($this->nodeTypesBag->all() as $nodeType) { + $tests[] = new TwigTest($nodeType->getName(), function ($mixed) use ($nodeType) { + return null !== $mixed && get_class($mixed) === $nodeType->getSourceEntityFullQualifiedClassName(); + }); + $tests[] = new TwigTest($nodeType->getSourceEntityClassName(), function ($mixed) use ($nodeType) { + return null !== $mixed && get_class($mixed) === $nodeType->getSourceEntityFullQualifiedClassName(); + }); + } + + return $tests; + } + + /** + * @param NodesSources|null $ns + * @param array|null $criteria + * @param array|null $order + * @return array + * @throws RuntimeError + */ + public function getChildren(NodesSources $ns = null, array $criteria = null, array $order = null) + { + if (null === $ns) { + if ($this->throwExceptions) { + throw new RuntimeError("Cannot get children from a NULL node-source."); + } else { + return []; + } + } + $defaultCrit = [ + 'node.parent' => $ns->getNode(), + 'translation' => $ns->getTranslation(), + ]; + + if (null !== $order) { + $defaultOrder = $order; + } else { + $defaultOrder = [ + 'node.position' => 'ASC', + ]; + } + + if (null !== $criteria) { + $defaultCrit = array_merge($defaultCrit, $criteria); + } + + return $this->nodeSourceApi->getBy($defaultCrit, $defaultOrder); + } + + /** + * @param NodesSources|null $ns + * @param array|null $criteria + * @param array|null $order + * @return NodesSources|null + * @throws RuntimeError + */ + public function getNext(NodesSources $ns = null, array $criteria = null, array $order = null) + { + if (null === $ns) { + if ($this->throwExceptions) { + throw new RuntimeError("Cannot get next sibling from a NULL node-source."); + } else { + return null; + } + } + /** @var NodesSourcesHandler $nodeSourceHandler */ + $nodeSourceHandler = $this->handlerFactory->getHandler($ns); + return $nodeSourceHandler->getNext($criteria, $order); + } + + /** + * @param NodesSources|null $ns + * @param array|null $criteria + * @param array|null $order + * @return NodesSources|null + * @throws RuntimeError + */ + public function getPrevious(NodesSources $ns = null, array $criteria = null, array $order = null) + { + if (null === $ns) { + if ($this->throwExceptions) { + throw new RuntimeError("Cannot get previous sibling from a NULL node-source."); + } else { + return null; + } + } + /** @var NodesSourcesHandler $nodeSourceHandler */ + $nodeSourceHandler = $this->handlerFactory->getHandler($ns); + return $nodeSourceHandler->getPrevious($criteria, $order); + } + + /** + * @param NodesSources|null $ns + * @param array|null $criteria + * @param array|null $order + * @return NodesSources|null + * @throws RuntimeError + */ + public function getLastSibling(NodesSources $ns = null, array $criteria = null, array $order = null) + { + if (null === $ns) { + if ($this->throwExceptions) { + throw new RuntimeError("Cannot get last sibling from a NULL node-source."); + } else { + return null; + } + } + /** @var NodesSourcesHandler $nodeSourceHandler */ + $nodeSourceHandler = $this->handlerFactory->getHandler($ns); + return $nodeSourceHandler->getLastSibling($criteria, $order); + } + + /** + * @param NodesSources|null $ns + * @param array|null $criteria + * @param array|null $order + * @return NodesSources|null + * @throws RuntimeError + */ + public function getFirstSibling(NodesSources $ns = null, array $criteria = null, array $order = null) + { + if (null === $ns) { + if ($this->throwExceptions) { + throw new RuntimeError("Cannot get first sibling from a NULL node-source."); + } else { + return null; + } + } + /** @var NodesSourcesHandler $nodeSourceHandler */ + $nodeSourceHandler = $this->handlerFactory->getHandler($ns); + return $nodeSourceHandler->getFirstSibling($criteria, $order); + } + + /** + * @param NodesSources|null $ns + * @return NodesSources|null + * @throws RuntimeError + */ + public function getParent(NodesSources $ns = null) + { + if (null === $ns) { + if ($this->throwExceptions) { + throw new RuntimeError("Cannot get parent from a NULL node-source."); + } else { + return null; + } + } + + return $ns->getParent(); + } + + /** + * @param NodesSources|null $ns + * @param array|null $criteria + * @return array + * @throws RuntimeError + */ + public function getParents(NodesSources $ns = null, array $criteria = null) + { + if (null === $ns) { + if ($this->throwExceptions) { + throw new RuntimeError("Cannot get parents from a NULL node-source."); + } else { + return []; + } + } + /** @var NodesSourcesHandler $nodeSourceHandler */ + $nodeSourceHandler = $this->handlerFactory->getHandler($ns); + return $nodeSourceHandler->getParents($criteria); + } + + /** + * @param NodesSources|null $ns + * @return array + * @throws RuntimeError + */ + public function getTags(NodesSources $ns = null) + { + if (null === $ns) { + if ($this->throwExceptions) { + throw new RuntimeError("Cannot get tags from a NULL node-source."); + } else { + return []; + } + } + /** @var NodesSourcesHandler $nodeSourceHandler */ + $nodeSourceHandler = $this->handlerFactory->getHandler($ns); + return $nodeSourceHandler->getTags(); + } +} diff --git a/lib/RoadizCoreBundle/src/TwigExtension/RoadizExtension.php b/lib/RoadizCoreBundle/src/TwigExtension/RoadizExtension.php new file mode 100644 index 00000000..62b7fd10 --- /dev/null +++ b/lib/RoadizCoreBundle/src/TwigExtension/RoadizExtension.php @@ -0,0 +1,68 @@ +settingsBag = $settingsBag; + $this->nodeTypesBag = $nodeTypesBag; + $this->previewResolver = $previewResolver; + $this->chrootResolver = $chrootResolver; + $this->cmsVersion = $cmsVersion; + $this->cmsVersionPrefix = $cmsVersionPrefix; + $this->hideRoadizVersion = $hideRoadizVersion; + $this->maxVersionsShowed = $maxVersionsShowed; + } + + /** + * @return array + */ + public function getGlobals(): array + { + return [ + 'cms_version' => !$this->hideRoadizVersion ? $this->cmsVersion : null, + 'cms_prefix' => !$this->hideRoadizVersion ? $this->cmsVersionPrefix : null, + 'max_versions_showed' => $this->maxVersionsShowed, + 'help_external_url' => 'http://docs.roadiz.io', + 'is_preview' => $this->previewResolver->isPreview(), + 'bags' => [ + 'settings' => $this->settingsBag, + 'nodeTypes' => $this->nodeTypesBag, + ], + 'chroot_resolver' => $this->chrootResolver, + 'meta' => [ + 'siteName' => $this->settingsBag->get('site_name'), + 'siteCopyright' => $this->settingsBag->get('site_copyright'), + 'siteDescription' => $this->settingsBag->get('seo_description'), + ] + ]; + } +} diff --git a/lib/RoadizCoreBundle/src/TwigExtension/RoutingExtension.php b/lib/RoadizCoreBundle/src/TwigExtension/RoutingExtension.php new file mode 100644 index 00000000..bfdd578d --- /dev/null +++ b/lib/RoadizCoreBundle/src/TwigExtension/RoutingExtension.php @@ -0,0 +1,101 @@ +generator = $generator; + $this->decorated = $decorated; + } + + /** + * {@inheritdoc} + */ + public function getFunctions(): array + { + return [ + new TwigFunction('url', [$this, 'getUrl'], ['is_safe_callback' => [$this, 'isUrlGenerationSafe']]), + new TwigFunction('path', [$this, 'getPath'], ['is_safe_callback' => [$this, 'isUrlGenerationSafe']]), + ]; + } + + /** + * @param string|object|null $name + * @param array $parameters + * @param bool $relative + * @return string + * @throws RuntimeError + */ + public function getPath($name, array $parameters = [], bool $relative = false): string + { + if (is_string($name)) { + return $this->decorated->getPath( + $name, + $parameters, + $relative + ); + } + if (null !== $name) { + return $this->generator->generate( + RouteObjectInterface::OBJECT_BASED_ROUTE_NAME, + array_merge($parameters, [RouteObjectInterface::ROUTE_OBJECT => $name]), + $relative ? UrlGeneratorInterface::RELATIVE_PATH : UrlGeneratorInterface::ABSOLUTE_PATH + ); + } + throw new RuntimeError('Cannot generate url with NULL route name'); + } + + /** + * @param string|object|null $name + * @param array $parameters + * @param bool $schemeRelative + * @return string + * @throws RuntimeError + */ + public function getUrl($name, array $parameters = [], bool $schemeRelative = false): string + { + if (is_string($name)) { + return $this->decorated->getUrl( + $name, + $parameters, + $schemeRelative + ); + } + if (null !== $name) { + return $this->generator->generate( + RouteObjectInterface::OBJECT_BASED_ROUTE_NAME, + array_merge($parameters, [RouteObjectInterface::ROUTE_OBJECT => $name]), + $schemeRelative ? UrlGeneratorInterface::NETWORK_PATH : UrlGeneratorInterface::ABSOLUTE_URL + ); + } + throw new RuntimeError('Cannot generate url with NULL route name'); + } + + public function isUrlGenerationSafe(Node $argsNode): array + { + return $this->decorated->isUrlGenerationSafe($argsNode); + } +} diff --git a/lib/RoadizCoreBundle/src/TwigExtension/TokenParser/TransChoiceTokenParser.php b/lib/RoadizCoreBundle/src/TwigExtension/TokenParser/TransChoiceTokenParser.php new file mode 100644 index 00000000..bcd01d02 --- /dev/null +++ b/lib/RoadizCoreBundle/src/TwigExtension/TokenParser/TransChoiceTokenParser.php @@ -0,0 +1,89 @@ + + * + * @deprecated since Symfony 4.2, use the "trans" tag with a "%count%" parameter instead + * + * @final since Symfony 4.4 + */ +class TransChoiceTokenParser extends AbstractTokenParser +{ + /** + * {@inheritdoc} + * + * @return Node + */ + public function parse(Token $token): Node + { + $lineno = $token->getLine(); + $stream = $this->parser->getStream(); + + $vars = new ArrayExpression([], $lineno); + + $count = $this->parser->getExpressionParser()->parseExpression(); + + $domain = null; + $locale = null; + + if ($stream->test('with')) { + // {% transchoice count with vars %} + $stream->next(); + $vars = $this->parser->getExpressionParser()->parseExpression(); + } + + if ($stream->test('from')) { + // {% transchoice count from "messages" %} + $stream->next(); + $domain = $this->parser->getExpressionParser()->parseExpression(); + } + + if ($stream->test('into')) { + // {% transchoice count into "fr" %} + $stream->next(); + $locale = $this->parser->getExpressionParser()->parseExpression(); + } + + $stream->expect(Token::BLOCK_END_TYPE); + + $body = $this->parser->subparse([$this, 'decideTransChoiceFork'], true); + + if (!$body instanceof TextNode && !$body instanceof AbstractExpression) { + throw new SyntaxError('A message inside a transchoice tag must be a simple text.', $body->getTemplateLine(), $stream->getSourceContext()); + } + + $stream->expect(Token::BLOCK_END_TYPE); + + return new TransNode($body, $domain, $count, $vars, $locale, $lineno, $this->getTag()); + } + + public function decideTransChoiceFork(Token $token): bool + { + return $token->test(['endtranschoice']); + } + + /** + * {@inheritdoc} + * + * @return string + */ + public function getTag(): string + { + return 'transchoice'; + } +} diff --git a/lib/RoadizCoreBundle/src/TwigExtension/TransChoiceExtension.php b/lib/RoadizCoreBundle/src/TwigExtension/TransChoiceExtension.php new file mode 100644 index 00000000..949b0243 --- /dev/null +++ b/lib/RoadizCoreBundle/src/TwigExtension/TransChoiceExtension.php @@ -0,0 +1,62 @@ +translator = $translator; + } + + public function getFilters(): array + { + return [ + new TwigFilter('transchoice', [$this, 'transchoice']), + ]; + } + + /** + * Returns the token parser instance to add to the existing list. + * + * @return AbstractTokenParser[] + */ + public function getTokenParsers(): array + { + return [ + // {% transchoice count %} + // {0} There is no apples|{1} There is one apple|]1,Inf] There is {{ count }} apples + // {% endtranschoice %} + new TransChoiceTokenParser(), + ]; + } + + /** + * @deprecated since Symfony 4.2, use the trans() method instead with a %count% parameter + */ + public function transchoice( + string $message, + int $count, + array $arguments = [], + ?string $domain = null, + ?string $locale = null + ): string { + return $this->translator->trans($message, array_merge(['%count%' => $count], $arguments), $domain, $locale); + } +} diff --git a/lib/RoadizCoreBundle/src/TwigExtension/TranslationExtension.php b/lib/RoadizCoreBundle/src/TwigExtension/TranslationExtension.php new file mode 100644 index 00000000..807a2fc7 --- /dev/null +++ b/lib/RoadizCoreBundle/src/TwigExtension/TranslationExtension.php @@ -0,0 +1,70 @@ +isRtl(); + } + + if (is_string($mixed)) { + return in_array($mixed, Translation::getRightToLeftLocales()); + } + + return false; + } + + /** + * @param string $iso + * @param string|null $locale + * @return string + */ + public function getCountryName(string $iso, ?string $locale = null): string + { + return Countries::getName($iso, $locale); + } + + /** + * @param string $iso + * @param string|null $locale + * + * @return string + */ + public function getLocaleName(string $iso, ?string $locale = null): string + { + return Locales::getName($iso, $locale); + } +} diff --git a/lib/RoadizCoreBundle/src/TwigExtension/TranslationMenuExtension.php b/lib/RoadizCoreBundle/src/TwigExtension/TranslationMenuExtension.php new file mode 100644 index 00000000..b1fdb2fd --- /dev/null +++ b/lib/RoadizCoreBundle/src/TwigExtension/TranslationMenuExtension.php @@ -0,0 +1,52 @@ +requestStack = $requestStack; + $this->translationViewer = $translationViewer; + } + + public function getFilters(): array + { + return [ + new TwigFilter('menu', [$this, 'getMenuAssignation']), + ]; + } + + /** + * @param TranslationInterface|null $translation + * @param bool $absolute + * + * @return array + * @throws ORMException + */ + public function getMenuAssignation(TranslationInterface $translation = null, bool $absolute = false) + { + if (null !== $translation) { + $this->translationViewer->setTranslation($translation); + return $this->translationViewer->getTranslationMenuAssignation($this->requestStack->getCurrentRequest(), $absolute); + } else { + return []; + } + } +} diff --git a/lib/RoadizCoreBundle/src/Webhook/Exception/TooManyWebhookTriggeredException.php b/lib/RoadizCoreBundle/src/Webhook/Exception/TooManyWebhookTriggeredException.php new file mode 100644 index 00000000..67163dae --- /dev/null +++ b/lib/RoadizCoreBundle/src/Webhook/Exception/TooManyWebhookTriggeredException.php @@ -0,0 +1,30 @@ +doNotTriggerBefore = $doNotTriggerBefore; + } + + /** + * @return \DateTimeImmutable + */ + public function getDoNotTriggerBefore(): \DateTimeImmutable + { + return $this->doNotTriggerBefore ?? \DateTimeImmutable::createFromMutable((new \DateTime())->add(new \DateInterval('PT30S'))); + } +} diff --git a/lib/RoadizCoreBundle/src/Webhook/Message/GenericJsonPostMessage.php b/lib/RoadizCoreBundle/src/Webhook/Message/GenericJsonPostMessage.php new file mode 100644 index 00000000..8d9f739f --- /dev/null +++ b/lib/RoadizCoreBundle/src/Webhook/Message/GenericJsonPostMessage.php @@ -0,0 +1,61 @@ +uri = $uri; + $this->payload = $payload; + } + + public function getRequest(): RequestInterface + { + return new Request( + 'POST', + $this->uri, + [ + 'Content-Type' => 'application/json', + 'Accept' => 'application/json' + ], + json_encode($this->payload ?? [], JSON_NUMERIC_CHECK | JSON_THROW_ON_ERROR) + ); + } + + /** + * @return array + */ + public function getOptions(): array + { + return [ + 'debug' => false, + 'timeout' => 3 + ]; + } + + /** + * @param Webhook $webhook + * @return static + */ + public static function fromWebhook(WebhookInterface $webhook) + { + return new self($webhook->getUri(), $webhook->getPayload()); + } +} diff --git a/lib/RoadizCoreBundle/src/Webhook/Message/GitlabPipelineTriggerMessage.php b/lib/RoadizCoreBundle/src/Webhook/Message/GitlabPipelineTriggerMessage.php new file mode 100644 index 00000000..875d856d --- /dev/null +++ b/lib/RoadizCoreBundle/src/Webhook/Message/GitlabPipelineTriggerMessage.php @@ -0,0 +1,80 @@ +uri = $uri; + $this->token = $token; + $this->ref = $ref; + $this->variables = $variables; + } + + public function getRequest(): RequestInterface + { + $postBody = [ + 'token' => $this->token, + 'ref' => $this->ref, + ]; + if (null !== $this->variables) { + $postBody['variables'] = $this->variables; + } + + return new Request( + 'POST', + $this->uri, + [ + 'Content-Type' => 'application/x-www-form-urlencoded', + 'Accept' => 'application/json' + ], + http_build_query($postBody) + ); + } + + /** + * @return array + */ + public function getOptions(): array + { + return [ + 'debug' => false, + 'timeout' => 3 + ]; + } + + /** + * @param WebhookInterface $webhook + * @return static + */ + public static function fromWebhook(WebhookInterface $webhook) + { + $payload = $webhook->getPayload(); + return new self( + $webhook->getUri(), + $payload['token'] ?? '', + $payload['ref'] ?? 'main', + $payload['variables'] ?? [] + ); + } +} diff --git a/lib/RoadizCoreBundle/src/Webhook/Message/NetlifyBuildHookMessage.php b/lib/RoadizCoreBundle/src/Webhook/Message/NetlifyBuildHookMessage.php new file mode 100644 index 00000000..76cdf7e1 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Webhook/Message/NetlifyBuildHookMessage.php @@ -0,0 +1,63 @@ +uri = $uri; + $this->payload = $payload; + } + + public function getRequest(): RequestInterface + { + if (null !== $this->payload) { + return new Request( + 'POST', + $this->uri, + [ + 'Content-Type' => 'application/x-www-form-urlencoded', + 'Accept' => 'application/json' + ], + http_build_query($this->payload) + ); + } + return new Request('POST', $this->uri); + } + + /** + * @return array + */ + public function getOptions(): array + { + return [ + 'debug' => false, + 'timeout' => 3 + ]; + } + + /** + * @param WebhookInterface $webhook + * @return static + */ + public static function fromWebhook(WebhookInterface $webhook) + { + return new self($webhook->getUri(), $webhook->getPayload()); + } +} diff --git a/lib/RoadizCoreBundle/src/Webhook/Message/WebhookMessage.php b/lib/RoadizCoreBundle/src/Webhook/Message/WebhookMessage.php new file mode 100644 index 00000000..012c98de --- /dev/null +++ b/lib/RoadizCoreBundle/src/Webhook/Message/WebhookMessage.php @@ -0,0 +1,16 @@ +getMessageType()) { + throw new \LogicException('Webhook message type is null.'); + } + + /** @var class-string $messageType */ + $messageType = $webhook->getMessageType(); + + if (!class_exists($messageType)) { + throw new \LogicException('Webhook message type does not exist.'); + } + if (!in_array(WebhookMessage::class, class_implements($messageType))) { + throw new \LogicException('Webhook message type does not implement ' . WebhookMessage::class); + } + + return $messageType::fromWebhook($webhook); + } +} diff --git a/lib/RoadizCoreBundle/src/Webhook/Message/WebhookMessageFactoryInterface.php b/lib/RoadizCoreBundle/src/Webhook/Message/WebhookMessageFactoryInterface.php new file mode 100644 index 00000000..e75c6a04 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Webhook/Message/WebhookMessageFactoryInterface.php @@ -0,0 +1,13 @@ +messageFactory = $messageFactory; + $this->messageBus = $messageBus; + $this->throttledWebhooksLimiter = $throttledWebhooksLimiter; + } + + /** + * @param WebhookInterface $webhook + * @throws \Exception + */ + public function dispatch(WebhookInterface $webhook): void + { + $doNotTriggerBefore = $webhook->doNotTriggerBefore(); + if ( + null !== $doNotTriggerBefore && + $doNotTriggerBefore > new \DateTime() + ) { + throw new TooManyWebhookTriggeredException(\DateTimeImmutable::createFromMutable($doNotTriggerBefore)); + } + $limiter = $this->throttledWebhooksLimiter->create($webhook->getId()); + $limit = $limiter->consume(); + // the argument of consume() is the number of tokens to consume + // and returns an object of type Limit + if (!$limit->isAccepted()) { + throw new TooManyWebhookTriggeredException($limit->getRetryAfter()); + } + $message = $this->messageFactory->createMessage($webhook); + $this->messageBus->dispatch(new Envelope($message)); + $webhook->setLastTriggeredAt(new \DateTime()); + } +} diff --git a/lib/RoadizCoreBundle/src/Webhook/WebhookDispatcher.php b/lib/RoadizCoreBundle/src/Webhook/WebhookDispatcher.php new file mode 100644 index 00000000..f5ef4355 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Webhook/WebhookDispatcher.php @@ -0,0 +1,10 @@ +security = $security; + } + + /** + * @inheritDoc + */ + public static function getSubscribedEvents(): array + { + return [ + 'workflow.node.guard' => ['guard'], + 'workflow.node.guard.publish' => ['guardPublish'], + 'workflow.node.guard.archive' => ['guardArchive'], + 'workflow.node.guard.delete' => ['guardDelete'], + ]; + } + + public function guard(GuardEvent $event): void + { + if (!$this->security->isGranted('ROLE_ACCESS_NODES')) { + $event->addTransitionBlocker(new TransitionBlocker( + 'User is not allowed to edit this node.', + '1' + )); + } + } + + public function guardPublish(GuardEvent $event): void + { + if (!$this->security->isGranted('ROLE_ACCESS_NODES_STATUS')) { + $event->addTransitionBlocker(new TransitionBlocker( + 'User is not allowed to publish this node.', + '1' + )); + } + } + + public function guardArchive(GuardEvent $event): void + { + /** @var Node $node */ + $node = $event->getSubject(); + if ($node->isLocked()) { + $event->addTransitionBlocker(new TransitionBlocker( + 'A locked node cannot be archived.', + '1' + )); + } + if (!$this->security->isGranted('ROLE_ACCESS_NODES_STATUS')) { + $event->addTransitionBlocker(new TransitionBlocker( + 'User is not allowed to archive this node.', + '1' + )); + } + } + + public function guardDelete(GuardEvent $event): void + { + /** @var Node $node */ + $node = $event->getSubject(); + if ($node->isLocked()) { + $event->addTransitionBlocker(new TransitionBlocker( + 'A locked node cannot be deleted.', + '1' + )); + } + if (!$this->security->isGranted('ROLE_ACCESS_NODES_DELETE')) { + $event->addTransitionBlocker(new TransitionBlocker( + 'User is not allowed to delete this node.', + '1' + )); + } + } +} diff --git a/lib/RoadizCoreBundle/src/Workflow/NodeWorkflow.php b/lib/RoadizCoreBundle/src/Workflow/NodeWorkflow.php new file mode 100644 index 00000000..e8b51897 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Workflow/NodeWorkflow.php @@ -0,0 +1,52 @@ +setInitialPlaces($this->toPlace(Node::DRAFT)) + ->addPlaces([ + $this->toPlace(Node::DRAFT), + $this->toPlace(Node::PENDING), + $this->toPlace(Node::PUBLISHED), + $this->toPlace(Node::ARCHIVED), + $this->toPlace(Node::DELETED) + ]) + ->addTransition(new Transition('review', $this->toPlace(Node::DRAFT), $this->toPlace(Node::PENDING))) + ->addTransition(new Transition('review', $this->toPlace(Node::PUBLISHED), $this->toPlace(Node::PENDING))) + ->addTransition(new Transition('reject', $this->toPlace(Node::PENDING), $this->toPlace(Node::DRAFT))) + ->addTransition(new Transition('reject', $this->toPlace(Node::PUBLISHED), $this->toPlace(Node::DRAFT))) + ->addTransition(new Transition('publish', $this->toPlace(Node::DRAFT), $this->toPlace(Node::PUBLISHED))) + ->addTransition(new Transition('publish', $this->toPlace(Node::PENDING), $this->toPlace(Node::PUBLISHED))) + ->addTransition(new Transition('publish', $this->toPlace(Node::PUBLISHED), $this->toPlace(Node::PUBLISHED))) + ->addTransition(new Transition('archive', $this->toPlace(Node::PUBLISHED), $this->toPlace(Node::ARCHIVED))) + ->addTransition(new Transition('unarchive', $this->toPlace(Node::ARCHIVED), $this->toPlace(Node::DRAFT))) + ->addTransition(new Transition('delete', $this->toPlace(Node::DRAFT), $this->toPlace(Node::DELETED))) + ->addTransition(new Transition('delete', $this->toPlace(Node::PENDING), $this->toPlace(Node::DELETED))) + ->addTransition(new Transition('delete', $this->toPlace(Node::PUBLISHED), $this->toPlace(Node::DELETED))) + ->addTransition(new Transition('delete', $this->toPlace(Node::ARCHIVED), $this->toPlace(Node::DELETED))) + ->addTransition(new Transition('undelete', $this->toPlace(Node::DELETED), $this->toPlace(Node::DRAFT))) + ->build() + ; + $markingStore = new MethodMarkingStore(true, 'status'); + parent::__construct($definition, $markingStore, $dispatcher, 'node'); + } + + protected function toPlace(int $legacyPlace): string + { + return (string) $legacyPlace; + } +} diff --git a/lib/RoadizCoreBundle/src/Xlsx/AbstractXlsxSerializer.php b/lib/RoadizCoreBundle/src/Xlsx/AbstractXlsxSerializer.php new file mode 100644 index 00000000..4e6340d4 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Xlsx/AbstractXlsxSerializer.php @@ -0,0 +1,48 @@ +translator = $translator; + } + + /** + * Serializes data. + * + * @param mixed $obj + * + * @return string + * @throws \PhpOffice\PhpSpreadsheet\Exception + * @throws \PhpOffice\PhpSpreadsheet\Writer\Exception + */ + public function serialize($obj): string + { + $data = $this->toArray($obj); + $exporter = new XlsxExporter($this->translator); + + return $exporter->exportXlsx($data); + } + + /** + * @return TranslatorInterface + */ + public function getTranslator(): TranslatorInterface + { + return $this->translator; + } +} diff --git a/lib/RoadizCoreBundle/src/Xlsx/NodeSourceXlsxSerializer.php b/lib/RoadizCoreBundle/src/Xlsx/NodeSourceXlsxSerializer.php new file mode 100644 index 00000000..b2579415 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Xlsx/NodeSourceXlsxSerializer.php @@ -0,0 +1,180 @@ +objectManager = $objectManager; + $this->urlGenerator = $urlGenerator; + } + + /** + * Create a simple associative array with a NodeSource. + * + * @param NodesSources|Collection|array|null $nodeSource + * @return array + */ + public function toArray($nodeSource): array + { + $data = []; + + if ($nodeSource instanceof NodesSources) { + if ($this->addUrls === true) { + $data['_url'] = $this->urlGenerator->generate( + RouteObjectInterface::OBJECT_BASED_ROUTE_NAME, + [ + RouteObjectInterface::ROUTE_OBJECT => $nodeSource + ], + UrlGeneratorInterface::ABSOLUTE_URL + ); + } + + $data['translation'] = $nodeSource->getTranslation()->getLocale(); + $data['title'] = $nodeSource->getTitle(); + $data['published_at'] = $nodeSource->getPublishedAt(); + $data['meta_title'] = $nodeSource->getMetaTitle(); + $data['meta_keywords'] = $nodeSource->getMetaKeywords(); + $data['meta_description'] = $nodeSource->getMetaDescription(); + + $data = array_merge($data, $this->getSourceFields($nodeSource)); + } elseif ($nodeSource instanceof Collection || is_array($nodeSource)) { + /* + * If asked to serialize a nodeSource collection + */ + foreach ($nodeSource as $singleSource) { + $data[] = $this->toArray($singleSource); + } + } + + return $data; + } + + /** + * @param NodesSources $nodeSource + * @return array + */ + protected function getSourceFields(NodesSources $nodeSource): array + { + $fields = $this->getFields($nodeSource->getNode()->getNodeType()); + + /* + * Create nodeSource default values + */ + $sourceDefaults = []; + foreach ($fields as $field) { + $getter = $field->getGetterName(); + $sourceDefaults[$field->getName()] = $nodeSource->$getter(); + } + + return $sourceDefaults; + } + + /** + * @param NodeTypeInterface $nodeType + * @return NodeTypeField[] + */ + protected function getFields(NodeTypeInterface $nodeType): array + { + $criteria = [ + 'nodeType' => $nodeType, + ]; + + if (true === $this->onlyTexts) { + $criteria['type'] = [ + AbstractField::STRING_T, + AbstractField::TEXT_T, + AbstractField::MARKDOWN_T, + AbstractField::RICHTEXT_T, + ]; + } else { + $criteria['type'] = [ + AbstractField::STRING_T, + AbstractField::DATETIME_T, + AbstractField::DATE_T, + AbstractField::RICHTEXT_T, + AbstractField::TEXT_T, + AbstractField::MARKDOWN_T, + AbstractField::BOOLEAN_T, + AbstractField::INTEGER_T, + AbstractField::DECIMAL_T, + AbstractField::EMAIL_T, + AbstractField::ENUM_T, + AbstractField::MULTIPLE_T, + AbstractField::COLOUR_T, + AbstractField::GEOTAG_T, + AbstractField::MULTI_GEOTAG_T, + ]; + } + + return $this->objectManager->getRepository(NodeTypeField::class) + ->findBy($criteria, ['position' => 'ASC']); + } + + /** + * {@inheritDoc} + */ + public function deserialize($string) + { + return null; + } + + /** + * Serialize only texts. + * + * @param bool $onlyTexts + * @return NodeSourceXlsxSerializer + */ + public function setOnlyTexts(bool $onlyTexts = true): self + { + $this->onlyTexts = $onlyTexts; + return $this; + } + + /** + * @param Request $request + * @param bool $forceLocale + * @return NodeSourceXlsxSerializer + */ + public function addUrls(Request $request, bool $forceLocale = false): self + { + $this->addUrls = true; + $this->request = $request; + $this->forceLocale = $forceLocale; + return $this; + } +} diff --git a/lib/RoadizCoreBundle/src/Xlsx/SerializerInterface.php b/lib/RoadizCoreBundle/src/Xlsx/SerializerInterface.php new file mode 100644 index 00000000..07229172 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Xlsx/SerializerInterface.php @@ -0,0 +1,39 @@ +translator = $translator; + } + + /** + * Export an array of data to XLSX format. + * + * @param \IteratorAggregate|array $data + * @param array $keys + * + * @return string + * @throws \PhpOffice\PhpSpreadsheet\Exception + * @throws \PhpOffice\PhpSpreadsheet\Writer\Exception + */ + public function exportXlsx($data, $keys = []) + { + $spreadsheet = new Spreadsheet(); + + // Set document properties + $spreadsheet->getProperties()->setCreator("Roadiz CMS") + ->setLastModifiedBy("Roadiz CMS") + ->setCategory(""); + + $spreadsheet->setActiveSheetIndex(0); + $activeSheet = $spreadsheet->getActiveSheet(); + $activeRow = 1; + $hasGlobalHeader = false; + + $headerStyles = [ + 'font' => [ + 'bold' => true, + 'color' => ['rgb' => 'FF0000'], + 'size' => 11, + 'name' => 'Verdana', + ], + 'width' => 50, + ]; + + /* + * Add headers row + */ + if (count($keys) > 0) { + foreach ($keys as $key => $value) { + $columnAlpha = Coordinate::stringFromColumnIndex($key + 1); + $activeSheet->getStyle($columnAlpha . ($activeRow))->applyFromArray($headerStyles); + $activeSheet->setCellValueByColumnAndRow($key + 1, $activeRow, $this->translator->trans($value)); + } + $activeRow++; + $hasGlobalHeader = true; + } + + $headerkeys = $keys; + + foreach ($data as $answer) { + /* + * If headers have changed + * we print them + */ + if ( + false === $hasGlobalHeader && + $headerkeys != array_keys($answer) + ) { + $headerkeys = array_keys($answer); + foreach ($headerkeys as $key => $value) { + $columnAlpha = Coordinate::stringFromColumnIndex($key + 1); + $activeSheet->getStyle($columnAlpha . $activeRow)->applyFromArray($headerStyles); + $activeSheet->setCellValueByColumnAndRow($key + 1, $activeRow, $this->translator->trans($value)); + } + $activeRow++; + } + + /* + * Print values + */ + $answer = array_values($answer); + foreach ($answer as $k => $value) { + $columnAlpha = Coordinate::stringFromColumnIndex($k + 1); + + if ( + $value instanceof Collection || + is_array($value) + ) { + continue; + } + + if ($value instanceof \DateTime) { + $value = Date::PHPToExcel($value); + $activeSheet->getStyle($columnAlpha . ($activeRow)) + ->getNumberFormat() + ->setFormatCode('dd.mm.yyyy hh:MM:ss'); + } + /* + * Set value into cell + */ + $activeSheet->getStyle($columnAlpha . $activeRow)->getAlignment()->setWrapText(true); + $activeSheet->setCellValueByColumnAndRow($k + 1, $activeRow, $this->translator->trans((string) $value)); + } + + $activeRow++; + } + + /* + * autosize + */ + foreach (range('A', $spreadsheet->getActiveSheet()->getHighestDataColumn()) as $col) { + $spreadsheet->getActiveSheet() + ->getColumnDimension($col) + ->setWidth(50); + } + + + // Set active sheet index to the first sheet, so Excel opens this as the first sheet + $writer = new Xlsx($spreadsheet); + ob_start(); + $writer->save('php://output'); + return ob_get_clean(); + } +} diff --git a/lib/RoadizCoreBundle/templates/ApiPlatformBundle/SwaggerUi/index.html.twig b/lib/RoadizCoreBundle/templates/ApiPlatformBundle/SwaggerUi/index.html.twig new file mode 100644 index 00000000..1b0970ee --- /dev/null +++ b/lib/RoadizCoreBundle/templates/ApiPlatformBundle/SwaggerUi/index.html.twig @@ -0,0 +1,130 @@ + + + + + {% if title %}{{ title }} - {% endif %}Roadiz CMS + + {% block stylesheet %} + + + + + + {% endblock %} + + {% set oauth_data = {'oauth': swagger_data.oauth|merge({'redirectUrl' : absolute_url(asset('bundles/apiplatform/swagger-ui/oauth2-redirect.html', assetPackage)) })} %} + {# json_encode(65) is for JSON_UNESCAPED_SLASHES|JSON_HEX_TAG to avoid JS XSS #} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ +{% if showWebby %} +
+
+{% endif %} + +
+ +{% block javascript %} +{% if (reDocEnabled and not swaggerUiEnabled) or (reDocEnabled and 're_doc' == active_ui) %} + + +{% else %} + + + +{% endif %} +{% endblock %} + + + diff --git a/lib/RoadizCoreBundle/templates/base.html.twig b/lib/RoadizCoreBundle/templates/base.html.twig new file mode 100644 index 00000000..19582651 --- /dev/null +++ b/lib/RoadizCoreBundle/templates/base.html.twig @@ -0,0 +1,14 @@ + + + + + + Document + + +{% block content %} + +{% endblock %} + + diff --git a/lib/RoadizCoreBundle/templates/customForm/base_custom_form.html.twig b/lib/RoadizCoreBundle/templates/customForm/base_custom_form.html.twig new file mode 100644 index 00000000..0111cce7 --- /dev/null +++ b/lib/RoadizCoreBundle/templates/customForm/base_custom_form.html.twig @@ -0,0 +1,26 @@ +{% set formattedLocale = request.locale|replace({'_': '-'})|lower %} + + + + + + {% block title %}{{ head.siteTitle }}{% endblock %} + + + + {% include '@RoadizRozier/partials/css-inject.html.twig' %} + + + + + + +
+ {% block content %} +

{% trans %}Welcome{% endtrans %}

+ {% endblock %} +
+ + + + diff --git a/lib/RoadizCoreBundle/templates/customForm/customForm.html.twig b/lib/RoadizCoreBundle/templates/customForm/customForm.html.twig new file mode 100644 index 00000000..445238db --- /dev/null +++ b/lib/RoadizCoreBundle/templates/customForm/customForm.html.twig @@ -0,0 +1,28 @@ +{% extends '@RoadizCore/customForm/base_custom_form.html.twig' %} + +{% block title %}{{ customForm.displayName }}{% endblock %} + +{% block content %} +
+
+

{{ customForm.displayName }}

+
{{ customForm.description|markdown }}
+

{% trans %}*.required.fields{% endtrans %}

+
+ +
+ {% form_theme form '@RoadizCore/customForm/customForms.html.twig' %} + {{ form_start(form, { attr: {id: 'add-custom-form-form'}}) }} + {{ form_widget(form) }} +
+ {% apply spaceless %} + + {% endapply %} +
+ {{ form_end(form) }} +
+
+{% endblock %} diff --git a/lib/RoadizCoreBundle/templates/customForm/customFormSent.html.twig b/lib/RoadizCoreBundle/templates/customForm/customFormSent.html.twig new file mode 100644 index 00000000..e9c4a655 --- /dev/null +++ b/lib/RoadizCoreBundle/templates/customForm/customFormSent.html.twig @@ -0,0 +1,16 @@ +{% extends '@RoadizCore/customForm/base_custom_form.html.twig' %} + +{% block title %}{{ customForm.displayName }}{% endblock %} + +{% block content %} +
+
+

{{ customForm.displayName }}

+
{{ customForm.description|markdown }}
+
+ +
+

{% trans %}customForm.answer.sent{% endtrans %}

+
+
+{% endblock %} diff --git a/lib/RoadizCoreBundle/templates/customForm/customForms.html.twig b/lib/RoadizCoreBundle/templates/customForm/customForms.html.twig new file mode 100644 index 00000000..fe849775 --- /dev/null +++ b/lib/RoadizCoreBundle/templates/customForm/customForms.html.twig @@ -0,0 +1,144 @@ +{% use "bootstrap_4_layout.html.twig" %} + +{# + # + # This file extends default symfony2 fields types + # It adds Roadiz CMS special types templates such as + # Markdown, document and childrenNodeTree fields + # + #} +{% block markdown_widget %} + {% apply spaceless %} + {# just let the textarea widget render the select tag #} + + {% endapply %} +{% endblock markdown_widget %} + +{% block separator_widget %} + {% apply spaceless %} + {# just let the textarea widget render the select tag #} +

{{label}}

+ {% endapply %} +{% endblock separator_widget %} + +{% block time_widget -%} + {% if widget == 'single_text' %} + {{- block('form_widget_simple') -}} + {% else -%} + {% set vars = widget == 'text' ? { 'attr': { 'size': 1 }} : {} %} +
+ {{ form_widget(form.hour, vars) }}{% if with_minutes %}:{{ form_widget(form.minute, vars) }}{% endif %}{% if with_seconds %}:{{ form_widget(form.second, vars) }}{% endif %} +
+ {%- endif %} +{%- endblock time_widget %} + +{# Override default error block #} +{% block form_errors -%} + {% if errors|length > 0 -%} +
    + {%- for error in errors -%} +
  • {{ error.message|trans }}
  • + {%- endfor -%} +
+ {%- endif %} +{%- endblock form_errors %} + +{# + # Override default form rendering + #} +{% block form_start -%} + {% set method = method|upper %} + {%- if method in ["GET", "POST"] -%} + {% set form_method = method %} + {%- else -%} + {% set form_method = "POST" %} + {%- endif -%} +
+ {%- if form_method != method -%} + + {%- endif -%} +{%- endblock form_start %} + +{% block choice_widget_collapsed -%} + {% if required and placeholder is none and not placeholder_in_choices and not multiple -%} + {% set required = false %} + {%- endif -%} + + {% if multiple %} + + {% else %} +
+ +
+ {% endif %} +{%- endblock choice_widget_collapsed %} + +{% block form_row -%} +
+ {% if form.vars.block_prefixes[1] != 'separator' %} + {{- form_label(form) -}} + {% endif %} + {{- form_errors(form) -}} + {% if form.vars.help ?? false %} +
+ {{ form.vars.help|trans|markdown }} +
+ {% endif %} + {{- form_widget(form) -}} +
+{%- endblock form_row %} + +{% block recaptcha_widget -%} + + + +{%- endblock recaptcha_widget %} diff --git a/lib/RoadizCoreBundle/templates/email/404.html.twig b/lib/RoadizCoreBundle/templates/email/404.html.twig new file mode 100644 index 00000000..fdace4ab --- /dev/null +++ b/lib/RoadizCoreBundle/templates/email/404.html.twig @@ -0,0 +1,157 @@ + + + + + Page Not Found :( + + + +
+

Not found :(

+

Sorry, but the page you were trying to view does not exist.

+

It looks like this was the result of either:

+
    +
  • a mistyped address
  • +
  • an out-of-date link
  • +
+ + +
+ + diff --git a/lib/RoadizCoreBundle/templates/email/base_email.html.twig b/lib/RoadizCoreBundle/templates/email/base_email.html.twig new file mode 100644 index 00000000..6852c658 --- /dev/null +++ b/lib/RoadizCoreBundle/templates/email/base_email.html.twig @@ -0,0 +1,60 @@ + + + + + + {% block title %}Your Message Subject or Title{% endblock %} + + + + + + + + +
+
+ + {% if headerImageSrc is defined %} + + + + {% endif %} + + + +
+ {{ title }} +
+ {% block content_table %} + + + {% if content is defined %} + + {% endif %} + +
{{ content|markdown }}
+ {% endblock %} +
+ {% if mailContact is defined %} + + {% endif %} + {% if disclaimer is defined %} + + {% endif %} +
+
+ + diff --git a/lib/RoadizCoreBundle/templates/email/base_email.txt.twig b/lib/RoadizCoreBundle/templates/email/base_email.txt.twig new file mode 100644 index 00000000..69fc0ba1 --- /dev/null +++ b/lib/RoadizCoreBundle/templates/email/base_email.txt.twig @@ -0,0 +1,18 @@ +{% block title %}Your Message Subject or Title{% endblock %} + +======== + +{% block content_table %} +{% if content is defined %} +{{- content -}} +{% endif %} +{% endblock %} + +{% if mailContact is defined %} +-------- +{% trans %}for.questions.email{% endtrans %} {{ mailContact|escape }}. +{% endif %} +{% if disclaimer is defined %} +-------- +{{ disclaimer }} +{% endif %} diff --git a/lib/RoadizCoreBundle/templates/email/forms/answerForm.html.twig b/lib/RoadizCoreBundle/templates/email/forms/answerForm.html.twig new file mode 100644 index 00000000..2fc13c63 --- /dev/null +++ b/lib/RoadizCoreBundle/templates/email/forms/answerForm.html.twig @@ -0,0 +1,42 @@ +{% extends '@RoadizCore/email/base_email.html.twig' %} + +{% block title %}{{ title }}{% endblock %} + +{% block content_table %} + + + + + + + + + + +
+

{% trans %}answer.form{% endtrans %}

+
+

{{ title }}

+
+ + + + +
+ + {% for field in fields %} + + + + + {% endfor %} +
{{ field.label|default(field.name|trans) }} + {% if field.name == "submittedAt" %} + {{ field.value|format_datetime("medium", "short", locale=app.request.locale) }} + {% else %} + {{ field.value }} + {% endif %} +
+
+
+{% endblock %} diff --git a/lib/RoadizCoreBundle/templates/email/forms/answerForm.txt.twig b/lib/RoadizCoreBundle/templates/email/forms/answerForm.txt.twig new file mode 100644 index 00000000..762450bc --- /dev/null +++ b/lib/RoadizCoreBundle/templates/email/forms/answerForm.txt.twig @@ -0,0 +1,13 @@ +{% extends '@RoadizCore/email/base_email.txt.twig' %} + +{% block title %}{{ title }}{% endblock %} + +{% block content_table %} +{% trans %}answer.form{% endtrans %} + +--- + +{% for field in fields %} +{{ field.label|default(field.name|trans) }}: {% if field.name == "submittedAt" %}{{ field.value|format_datetime("medium", "short", locale=app.request.locale) }}{% else %}{{ field.value }}{% endif %} +{% endfor %} +{% endblock %} diff --git a/lib/RoadizCoreBundle/templates/email/forms/contactForm.html.twig b/lib/RoadizCoreBundle/templates/email/forms/contactForm.html.twig new file mode 100644 index 00000000..bfcbc788 --- /dev/null +++ b/lib/RoadizCoreBundle/templates/email/forms/contactForm.html.twig @@ -0,0 +1,46 @@ +{% extends '@RoadizCore/email/base_email.html.twig' %} + +{% block title %}{{ title }}{% endblock %} + +{% block content_table %} + + + + + + + + + + +
+

{{ (emailType|default('contact.form'))|trans }}

+
+

{{ title }}

+
+ + + + +
+ + {% for field in fields %} + {% if not field.name %} + + + + {% elseif not field.value %} + + + + {% else %} + + + + + {% endif %} + {% endfor %} +
{{ field.value }}
{{ field.name|trans }}
{{ field.name|trans }}{{ field.value }}
+
+
+{% endblock %} diff --git a/lib/RoadizCoreBundle/templates/email/forms/contactForm.txt.twig b/lib/RoadizCoreBundle/templates/email/forms/contactForm.txt.twig new file mode 100644 index 00000000..dc94255b --- /dev/null +++ b/lib/RoadizCoreBundle/templates/email/forms/contactForm.txt.twig @@ -0,0 +1,17 @@ +{% extends '@RoadizCore/email/base_email.txt.twig' %} + +{% block title %}{{ title }}{% endblock %} + +{% block content_table %} +{% trans %}contact.form{% endtrans %} + +{% for field in fields %} +{% if not field.name %} +{{ field.value }} + +{% elseif not field.value %} +{% else %} +{{ field.name|trans }} : {{ field.value }} +{% endif %} +{% endfor %} +{% endblock %} diff --git a/lib/RoadizCoreBundle/templates/email/users/reset_password_email.html.twig b/lib/RoadizCoreBundle/templates/email/users/reset_password_email.html.twig new file mode 100644 index 00000000..b99e6a4a --- /dev/null +++ b/lib/RoadizCoreBundle/templates/email/users/reset_password_email.html.twig @@ -0,0 +1,56 @@ +{% extends '@RoadizCore/email/base_email.html.twig' %} + +{% block title %}{% trans %}reset.password.request{% endtrans %}{% endblock %} + +{% block content_table %} + + + + + + + +
+ {% block content_title %} +

{% trans %}reset.password.request{% endtrans %}

+ {% endblock %} +
+ + + + + + + + + + +
+ {% block content_subtitle %} +

{{ 'you.asked.for.a.password.reset.on.%site%'|trans({'%site%':site})|escape }}

+ {% endblock %} +
+

{% trans %}you.need.to.choose.a.new.password.using.following.link{% endtrans %}

+

+ +

+ {% block content_disclaimer %} +

+ {% trans %}if.you.didnt.request.this.password.reset.ignore.this.email{% endtrans %} +

+ {% endblock %} +
+

{% trans %}as.a.reminder.here.are.your.credentials{% endtrans %}

+ + + + + + + + + +
{% trans %}username{% endtrans %}{{ user.username|escape }}
{% trans %}email{% endtrans %}{{ user.email|escape }}
+
+
+{% endblock %} diff --git a/lib/RoadizCoreBundle/templates/email/users/reset_password_email.txt.twig b/lib/RoadizCoreBundle/templates/email/users/reset_password_email.txt.twig new file mode 100644 index 00000000..3e231495 --- /dev/null +++ b/lib/RoadizCoreBundle/templates/email/users/reset_password_email.txt.twig @@ -0,0 +1,24 @@ +{% extends '@RoadizCore/email/base_email.txt.twig' %} + +{% block title %}{% trans %}reset.password.request{% endtrans %}{% endblock %} + +{% block content_table %} +{% block content_subtitle %}{{ 'you.asked.for.a.password.reset.on.%site%'|trans({'%site%':site}) }}{% endblock %} + +{% trans %}you.need.to.choose.a.new.password.using.following.link{% endtrans %} + +--- +{{ resetLink|raw }} +--- + +{% block content_disclaimer %} +{% trans %}if.you.didnt.request.this.password.reset.ignore.this.email{% endtrans %} +{% endblock %} + +{% trans %}as.a.reminder.here.are.your.credentials{% endtrans %} +{% trans %}username{% endtrans %}: +{{ user.username }} + +{% trans %}email{% endtrans %}: +{{ user.email }} +{% endblock %} diff --git a/lib/RoadizCoreBundle/templates/email/users/welcome_user_email.html.twig b/lib/RoadizCoreBundle/templates/email/users/welcome_user_email.html.twig new file mode 100644 index 00000000..868f9747 --- /dev/null +++ b/lib/RoadizCoreBundle/templates/email/users/welcome_user_email.html.twig @@ -0,0 +1,14 @@ +{% extends '@RoadizCore/email/users/reset_password_email.html.twig' %} + +{% block title %}{{ 'welcome.user.email.%site%'|trans({'%site%':site})|escape }}{% endblock %} + +{% block content_title %} +

{% trans %}your.user.account{% endtrans %}

+{% endblock %} + +{% block content_subtitle %} +

{{ 'welcome.user.email.%site%'|trans({'%site%':site})|escape }}

+{% endblock %} + +{% block content_disclaimer %} +{% endblock %} diff --git a/lib/RoadizCoreBundle/templates/email/users/welcome_user_email.txt.twig b/lib/RoadizCoreBundle/templates/email/users/welcome_user_email.txt.twig new file mode 100644 index 00000000..2445dee9 --- /dev/null +++ b/lib/RoadizCoreBundle/templates/email/users/welcome_user_email.txt.twig @@ -0,0 +1,7 @@ +{% extends '@RoadizCore/email/users/reset_password_email.txt.twig' %} + +{% block title %}{{ 'welcome.user.email.%site%'|trans({'%site%':site}) }}{% endblock %} +{% block content_subtitle %}{% trans %}your.user.account{% endtrans %}{% endblock %} + +{% block content_disclaimer %} +{% endblock %} diff --git a/lib/RoadizCoreBundle/templates/emerg.html b/lib/RoadizCoreBundle/templates/emerg.html new file mode 100644 index 00000000..253a7681 --- /dev/null +++ b/lib/RoadizCoreBundle/templates/emerg.html @@ -0,0 +1,290 @@ + + + + + {{ human_message }} + + + + +
+
+ + +
+

{{ human_message }}

+

{{ exception }}

+

{{ message }}

+

Please contact your site administrator, or post an issue on Github with following informations:

+
{{ details }}
+
+
+
+ + diff --git a/lib/RoadizCoreBundle/templates/fonts/fontfamily.css.twig b/lib/RoadizCoreBundle/templates/fonts/fontfamily.css.twig new file mode 100644 index 00000000..cb64b422 --- /dev/null +++ b/lib/RoadizCoreBundle/templates/fonts/fontfamily.css.twig @@ -0,0 +1,37 @@ +{% for item in fonts %} +{% set font = item.font %} +{% set fontAlternatives = [] %} +{% if font.getEOTFilename %} +{% set fontAlternatives = fontAlternatives|merge([ + 'url(\'' ~ path('FontFile', {'filename': font.hash, 'variant': font.variant, 'extension': "eot"}) ~ '\') format(\'embedded-opentype\')' +]) %} +{% endif %} +{% if font.getWOFF2Filename %} +{% set fontAlternatives = fontAlternatives|merge([ + 'url(\'' ~ path('FontFile', {'filename': font.hash, 'variant': font.variant, 'extension': "woff2"}) ~ '\') format(\'woff2\')' +]) %} +{% endif %} +{% if font.getWOFFFilename %} +{% set fontAlternatives = fontAlternatives|merge([ + 'url(\'' ~ path('FontFile', {'filename': font.hash, 'variant': font.variant, 'extension': "woff"}) ~ '\') format(\'woff\')' +]) %} +{% endif %} +{% if font.getOTFFilename %} +{% set fontAlternatives = fontAlternatives|merge([ + 'url(\'' ~ path('FontFile', {'filename': font.hash, 'variant': font.variant, 'extension': "otf"}) ~ '\') format(\'opentype\')' +]) %} +{% endif %} +{% if font.getSVGFilename %} +{% set fontAlternatives = fontAlternatives|merge([ + 'url(\'' ~ path('FontFile', {'filename': font.hash, 'variant': font.variant, 'extension': "svg"}) ~ '\') format(\'svg\')' +]) %} +{% endif %} +@font-face { + font-family: "{{ font.hash }}"; + font-display: swap; + src: {{ fontAlternatives|join(', ')|raw }}; +{% for key, value in font.fontVariantInfos %} + font-{{ key }}: {{ value }}; +{% endfor %} +} +{% endfor %} diff --git a/lib/RoadizCoreBundle/templates/nodeSource/default.html.twig b/lib/RoadizCoreBundle/templates/nodeSource/default.html.twig new file mode 100644 index 00000000..d74c57a7 --- /dev/null +++ b/lib/RoadizCoreBundle/templates/nodeSource/default.html.twig @@ -0,0 +1,8 @@ +{% extends "@RoadizCore/base.html.twig" %} + +{% block content %} +

{{ nodeSource.title }}

+ {% if nodeSource.content is defined %} + {{ nodeSource.content|markdown }} + {% endif %} +{% endblock %} diff --git a/lib/RoadizCoreBundle/themes/.gitkeep b/lib/RoadizCoreBundle/themes/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/lib/RoadizCoreBundle/translations/core/messages.ar.xlf b/lib/RoadizCoreBundle/translations/core/messages.ar.xlf new file mode 100644 index 00000000..3a201e75 --- /dev/null +++ b/lib/RoadizCoreBundle/translations/core/messages.ar.xlf @@ -0,0 +1,146 @@ + + + + + + + welcome.user.email.%site% + تم إنشاء حساب مستخدم لك على %site% + + + your.user.account + حساب المستخدم الخاص بك + + + username + اسم المستخدم + + + email + البريد الإلكتروني + + + password + كلمة المرور + + + for.questions.email + للأسئلة، اتصل بنا على البريد الإلكتروني: + + + + contact.form + نموذج الاتصال + + + new.contact.form.%site% + نموذج اتصال جديد %site% + + + form.successfully.sent + تم إرسال نموذجك بنجاح + + + form.has.errors + النموذج يحتوي على أخطاء + + + %field%.is.mandatory + %field% إلزامي + + + email.not.valid + عنوان البريد الإلكتروني الخاص بك غير صالح + + + date + التاريخ + + + ip.address + ip address (عنوان بروتوكول الإنترنت الخاص بك) + + + submittedAt + تم تقديمه في + + + form.submit + نموذج التقديم + + + answer.form + نموذج الإجابة + + + customForm.%name%.send + الإجابة %name% أُرسِلت. + + + new.answer.form.%site% + نموذج إجابة جديدة من %site% + + + file.not.accepted + لم نتمكن من قبول الملف المرفق. + + + customForm.answer.sent + تم إرسال إجابتك بنجاح. + + + none + لا شيء + + + reset.password.request + طلب إعادة تعيين كلمة المرور + + + you.asked.for.a.password.reset.on.%site% + لقد طلبت إعادة تعيين كلمة المرور في %site% + + + you.need.to.choose.a.new.password.using.following.link + عليك أن تقوم بإنشاء كلمة مرور جديدة لحسابك بواسطة استخدام الرابط التالي. + + + as.a.reminder.here.are.your.credentials + كتذكير لك، ها هي أوراق اعتمادك العامة. + + + if.you.didnt.request.this.password.reset.ignore.this.email + إن لم تطلب رسالة البريد الإلكتروني هذه، تجاهلها فقط ولن يتم تغيير كلمة المرور الخاصة بك. + + + reset_your_password + قم بإعادة تعيين كلمة المرور الخاصة بك + + + you.need.to.fill.this.required.field + عليك بكتابة هذا الحقل المطلوب. + + + *.required.fields + * حقول مطلوبة + + + website.is.under.maintenance + الموقع تحت الصيانة + + + website.is.under.maintenance.we.will.be.back.soon + الموقع تحت الصيانة، وسيتم الانتهاء قريبًا! + + + yaml.is.not.valid.{{ error }} + يمل غير صالح {{ error }} + + + value.is.already.used + القيمة المستخدمة بالفعل + Error message meaning that user can’t use that value for this object (eg. Object name is already used). + + + + diff --git a/lib/RoadizCoreBundle/translations/core/messages.de.xlf b/lib/RoadizCoreBundle/translations/core/messages.de.xlf new file mode 100644 index 00000000..a7eb4abf --- /dev/null +++ b/lib/RoadizCoreBundle/translations/core/messages.de.xlf @@ -0,0 +1,158 @@ + + + + + + + welcome.user.email.%site% + Willkommen auf %site% + + + your.user.account + Ihr Benutzerkonto + + + username + Benutzername + + + email + E-Mail + + + password + Passwort + + + for.questions.email + Haben Sie Fragen? Kontaktieren Sie uns + + + + contact.form + Kontaktformular + + + new.contact.form.%site% + Neues Kontaktformular auf %site% + + + form.successfully.sent + Formular erfolgreich gesendet + + + form.has.errors + Im Formular befinden sich Fehler. + + + %field%.is.mandatory + Das Feld %field% ist verpflichtend + + + email.not.valid + Ungültige E-Mail Adresse + + + date + Datum + + + ip.address + IP Adresse + + + submittedAt + Abgesendet am + + + form.submit + Formular absenden + + + answer.form + Antwortformular + + + customForm.%name%.send + Das Formular %name% wurde gesendet + + + new.answer.form.%site% + Neues Antwortformular auf %site% + + + file.not.accepted + Datei nicht akzeptiert + + + customForm.answer.sent + Ihr Antwort wurde abgesendet + + + none + Keine + + + reset.password.request + Passwortanfrage + + + you.asked.for.a.password.reset.on.%site% + Sie wollen das Passwort auf %site% zurücksetzen + + + you.need.to.choose.a.new.password.using.following.link + Sie müssen unter dem folgenden Link ein neues Passwort festlegen + + + as.a.reminder.here.are.your.credentials + Als Erinnerung, hier sind ihre Daten + + + if.you.didnt.request.this.password.reset.ignore.this.email + Wenn sie ihr Passwort nicht zurücksetzen wollen ignorieren sie diese E-Mail + + + reset_your_password + Ihr Passwort zurücksetzen + + + you.need.to.fill.this.required.field + Sie müssen dieses benötigte Feld ausfüllen + + + *.required.fields + * Pflichtfelder + + + website.is.under.maintenance + Seite wird zurzeit gewartet + + + website.is.under.maintenance.we.will.be.back.soon + Seite wird zurzeit gewartet und ist bald wieder erreichbar. + + + yaml.is.not.valid.{{ error }} + YAML ist nicht gültig: {{ error }} + + + value.is.already.used + Wert bereits in Benutzung + Error message meaning that user can’t use that value for this object (eg. Object name is already used). + + + password_should_contains_at_least_one_capital_one_digit + das_Passwort_sollte_mindestens_eine_Zahl_beinhalten + + + password_blacklisted + password_gesperrt + + + password_should_be_at_least_{{length}}_characters_long + passwort_sollte_mindestens{{length}}_Zeichen_haben + + + + diff --git a/lib/RoadizCoreBundle/translations/core/messages.en.xlf b/lib/RoadizCoreBundle/translations/core/messages.en.xlf new file mode 100644 index 00000000..9a7f371a --- /dev/null +++ b/lib/RoadizCoreBundle/translations/core/messages.en.xlf @@ -0,0 +1,158 @@ + + + + + + + welcome.user.email.%site% + A user account has been created for you on %site%. + + + your.user.account + Your user account + + + username + Username + + + email + Email + + + password + Password + + + for.questions.email + Any questions? Email us at: + + + + contact.form + Contact form + + + new.contact.form.%site% + New contact request for %site% + + + form.successfully.sent + Your form has been successfully sent + + + form.has.errors + Your form is not valid, please fill correctly mandatory fields. + + + %field%.is.mandatory + Field %field% is mandatory + + + email.not.valid + Your email is not valid + + + date + Date + + + ip.address + IP address + + + submittedAt + Submitted on + + + form.submit + Submit + + + answer.form + Answer form. + + + customForm.%name%.send + The answer "%name%" sent. + + + new.answer.form.%site% + New answer from "%site%" form. + + + file.not.accepted + Attached file could not be accepted. + + + customForm.answer.sent + Your answer has been successfully sent. + + + none + None + + + reset.password.request + Reset password request + + + you.asked.for.a.password.reset.on.%site% + You asked for a password reset on “%site%” + + + you.need.to.choose.a.new.password.using.following.link + You need to create a new password for your account by clicking on the following link. + + + as.a.reminder.here.are.your.credentials + As a reminder, here are your public credentials. + + + if.you.didnt.request.this.password.reset.ignore.this.email + If you didn’t ask for this email, just ignore it and your password won’t be changed. + + + reset_your_password + Reset your password + + + you.need.to.fill.this.required.field + You need to fill this required field. + + + *.required.fields + * Required fields + + + website.is.under.maintenance + Website is under maintenance + + + website.is.under.maintenance.we.will.be.back.soon + Website is currently under maintenance, it will be back soon! + + + yaml.is.not.valid.{{ error }} + YAML code is not valid ({{ error }}). + + + value.is.already.used + This value is already used. + Error message meaning that user can’t use that value for this object (eg. Object name is already used). + + + password_should_contains_at_least_one_capital_one_digit + Password should contain at least one capital letter and one digit. + + + password_blacklisted + Password is blacklisted + + + password_should_be_at_least_{{length}}_characters_long + Password should be at least {{length}} characters long + + + + diff --git a/lib/RoadizCoreBundle/translations/core/messages.es.xlf b/lib/RoadizCoreBundle/translations/core/messages.es.xlf new file mode 100644 index 00000000..c238c527 --- /dev/null +++ b/lib/RoadizCoreBundle/translations/core/messages.es.xlf @@ -0,0 +1,158 @@ + + + + + + + welcome.user.email.%site% + Una cuenta de usuario ha sido creada para ti en %site% + + + your.user.account + Tu cuenta de usuario + + + username + nombre de usuario + + + email + correo + + + password + contraseña + + + for.questions.email + Preguntas? Envíanos un correo a: + + + + contact.form + Formulario de contacto + + + new.contact.form.%site% + Nuevo formulario de contacto en %site% + + + form.successfully.sent + Formulario correctamente enviado + + + form.has.errors + Este formulario contiene errores + + + %field%.is.mandatory + El campo %field% es mandatorio + + + email.not.valid + Correo invalido + + + date + fecha + + + ip.address + Dirección IP + + + submittedAt + Enviado en + + + form.submit + Entregar formulario + + + answer.form + Responder formulario + + + customForm.%name%.send + El formulario %name% ha sido enviado + + + new.answer.form.%site% + Nuevas respuestas del formulario %site% + + + file.not.accepted + Archivo adjunto no aceptado + + + customForm.answer.sent + Su formulario ha sido enviado correctamente + + + none + ninguno + + + reset.password.request + Petición de reinicio de contraseña + + + you.asked.for.a.password.reset.on.%site% + Usted pidió por un reinicio de contraseña en %site% + + + you.need.to.choose.a.new.password.using.following.link + Usted debe escoger una nueva contraseña utilizando el siguiente enlace + + + as.a.reminder.here.are.your.credentials + Como recordatorio aquí están sus credenciales + + + if.you.didnt.request.this.password.reset.ignore.this.email + Si usted no pidió este reinicio de contraseña, ignore este correo + + + reset_your_password + Reiniciar su contraseña + + + you.need.to.fill.this.required.field + Usted debe llenar este campo obligatorio + + + *.required.fields + * campos obligatorios + + + website.is.under.maintenance + El sitio web esta en mantenimiento + + + website.is.under.maintenance.we.will.be.back.soon + El sitio web está en mantenimiento. Estaremos de vuelta pronto. + + + yaml.is.not.valid.{{ error }} + Código YAML no es válido {{ error }} + + + value.is.already.used + Valor ya utilizado + Error message meaning that user can’t use that value for this object (eg. Object name is already used). + + + password_should_contains_at_least_one_capital_one_digit + La contraseña debe contener al menos una letra mayúscula y un dígito. + + + password_blacklisted + La contraseña se encuentra en la lista negra. + + + password_should_be_at_least_{{length}}_characters_long + la contraseña debe de ser de al menos {{length}} caracteres de largo. + + + + diff --git a/lib/RoadizCoreBundle/translations/core/messages.fr.xlf b/lib/RoadizCoreBundle/translations/core/messages.fr.xlf new file mode 100644 index 00000000..6ae70f2c --- /dev/null +++ b/lib/RoadizCoreBundle/translations/core/messages.fr.xlf @@ -0,0 +1,158 @@ + + + + + + + welcome.user.email.%site% + Un compte utilisateur vous a été créé sur %site%. + + + your.user.account + Votre compte utilisateur + + + username + Nom d’utilisateur + + + email + Courriel + + + password + Mot de passe + + + for.questions.email + Des questions ? Contactez-nous à : + + + + contact.form + Formulaire de contact + + + new.contact.form.%site% + Nouveau formulaire de contact sur %site% + + + form.successfully.sent + Votre formulaire a bien été envoyé + + + form.has.errors + Votre formulaire n’est pas valide, merci de remplir correctement les champs obligatoires. + + + %field%.is.mandatory + Le champ %field% est obligatoire + + + email.not.valid + Votre courriel n’est pas valide + + + date + Date + + + ip.address + Adresse IP + + + submittedAt + Envoyé le + + + form.submit + Envoyer + + + answer.form + Formulaire de réponse + + + customForm.%name%.send + Le formulaire «%name%» a bien été envoyé + + + new.answer.form.%site% + Nouvelle réponse sur «%site%» + + + file.not.accepted + Le fichier joint n’a pas été accepté. + + + customForm.answer.sent + Votre réponse a bien été envoyée. + + + none + Aucun + + + reset.password.request + Demande de réinitialisation du mot de passe + + + you.asked.for.a.password.reset.on.%site% + Vous avez demandé à changer le mot de passe de votre compte sur « %site% » + + + you.need.to.choose.a.new.password.using.following.link + Vous devez choisir un nouveau mot de passe en cliquant sur le lien suivant. + + + as.a.reminder.here.are.your.credentials + Pour rappel, voici vos identifiants publics actuels + + + if.you.didnt.request.this.password.reset.ignore.this.email + Si vous n’avez pas sollicité cet email, veuillez l’ignorer, votre mot de passe ne sera pas changé. + + + reset_your_password + Réinitialiser votre mot de passe + + + you.need.to.fill.this.required.field + Vous devez remplir ce champ requis + + + *.required.fields + * Champs requis + + + website.is.under.maintenance + Le site est en maintenance + + + website.is.under.maintenance.we.will.be.back.soon + Le site est actuellement en maintenance, il sera à nouveau disponible d’ici quelques minutes. + + + yaml.is.not.valid.{{ error }} + Le code YAML n’est pas valide ({{ error }}). + + + value.is.already.used + Cette valeur est déjà utilisée. + Error message meaning that user can’t use that value for this object (eg. Object name is already used). + + + password_should_contains_at_least_one_capital_one_digit + Le mot de passe doit contenir au minimum une majuscule et un chiffre + + + password_blacklisted + Ce mot de passe est interdit car trop utilisé et non sécurisé + + + password_should_be_at_least_{{length}}_characters_long + Le mot de passe doit posséder au minimum {{length}} caractères + + + + diff --git a/lib/RoadizCoreBundle/translations/core/messages.id.xlf b/lib/RoadizCoreBundle/translations/core/messages.id.xlf new file mode 100644 index 00000000..6f8c72ec --- /dev/null +++ b/lib/RoadizCoreBundle/translations/core/messages.id.xlf @@ -0,0 +1,146 @@ + + + + + + + welcome.user.email.%site% + selamat datang pengguna email.%site% + + + your.user.account + akun pengguna anda + + + username + nama pengguna + + + email + Email + + + password + kata sandi + + + for.questions.email + Email untuk pertanyaan + + + + contact.form + formulir kontak + + + new.contact.form.%site% + formulir kontak baru.%site% + + + form.successfully.sent + formulir.berhasil.dikirim + + + form.has.errors + bentuk memiliki kesalahan + + + %field%.is.mandatory + %field%.adalah wajib + + + email.not.valid + email tidak valid + + + date + tanggal + + + ip.address + alamat Ip + + + submittedAt + disampaikan pada + + + form.submit + formulir kirim + + + answer.form + bentuk jawaban + + + customForm.%name%.send + kirim bentuk kustom.%name% + + + new.answer.form.%site% + bentuk jawaban baru.%site% + + + file.not.accepted + file tidak diterima + + + customForm.answer.sent + kirm bentuk kustom menjawab + + + none + tidak ada + + + reset.password.request + permintaan reset kata sandi + + + you.asked.for.a.password.reset.on.%site% + kamu tanya untuk menghidupkan ulang kata sandi.%site% + + + you.need.to.choose.a.new.password.using.following.link + kamu perlu untuk memilih kata sandi baru menggunakan tautan berikut + + + as.a.reminder.here.are.your.credentials + sebagai peringatan di sini kamu adalah kredensial + + + if.you.didnt.request.this.password.reset.ignore.this.email + jika kamu tidak meminta perubahan kata sandi abaikan surel ini + + + reset_your_password + reset kata sandi anda + + + you.need.to.fill.this.required.field + anda harus mengisi kolom ini + + + *.required.fields + *.bidang wajib + + + website.is.under.maintenance + situs web sedang dalam pemeliharaan + + + website.is.under.maintenance.we.will.be.back.soon + situs web sedang dalam pemeliharaan kita akan segera kembali + + + yaml.is.not.valid.{{ error }} + yaml tidak sah.{{ error }} + + + value.is.already.used + nolai sudah digunakan + Error message meaning that user can’t use that value for this object (eg. Object name is already used). + + + + diff --git a/lib/RoadizCoreBundle/translations/core/messages.it.xlf b/lib/RoadizCoreBundle/translations/core/messages.it.xlf new file mode 100644 index 00000000..7f50de6c --- /dev/null +++ b/lib/RoadizCoreBundle/translations/core/messages.it.xlf @@ -0,0 +1,146 @@ + + + + + + + welcome.user.email.%site% + Un account è stata creato per te su %site% + + + your.user.account + Il tuo account + + + username + Username + + + email + Email + + + password + Password + + + for.questions.email + Domande? scrivi a: + + + + contact.form + Modulo di contatto + + + new.contact.form.%site% + Nuova richiesta di contatto per %site% + + + form.successfully.sent + Modulo inviato con successo + + + form.has.errors + Il tuo modulo non è valido, per favore completa correttamente i campi obbligatori. + + + %field%.is.mandatory + Il campo %field% è obbligatorio + + + email.not.valid + La tua email non è valida + + + date + Data + + + ip.address + Indirizzo IP + + + submittedAt + Consegnato su + + + form.submit + Inviare + + + answer.form + Rispondere al modulo. + + + customForm.%name%.send + La risposta %name% è stata inviata. + + + new.answer.form.%site% + Nuova risposta dal modulo %site%. + + + file.not.accepted + Il file allegato non può essere accettato. + + + customForm.answer.sent + La sua risposta è stata inviata con successo. + + + none + Nessuno + + + reset.password.request + Reimpostazione password richiesta + + + you.asked.for.a.password.reset.on.%site% + Hai richiesto il cambio della password su %site% + + + you.need.to.choose.a.new.password.using.following.link + Devi scegliere una nuova password usando il seguente link + + + as.a.reminder.here.are.your.credentials + Come promemoria qui ci sono le tue credenziali + + + if.you.didnt.request.this.password.reset.ignore.this.email + Se non hai richiesto questa email, ignorala e la tua password non sarà cambiata. + + + reset_your_password + Reimposta la tua password + + + you.need.to.fill.this.required.field + Devi completare questo campo obbligatorio. + + + *.required.fields + * Campi obbligatori + + + website.is.under.maintenance + Il sito è in manutenzione + + + website.is.under.maintenance.we.will.be.back.soon + il sito è in manutenzione, sarà pronto a breve! + + + yaml.is.not.valid.{{ error }} + Il codice YAML non è valido ({{ error }}). + + + value.is.already.used + Valore già utilizzato. + Error message meaning that user can’t use that value for this object (eg. Object name is already used). + + + + diff --git a/lib/RoadizCoreBundle/translations/core/messages.ru.xlf b/lib/RoadizCoreBundle/translations/core/messages.ru.xlf new file mode 100644 index 00000000..398bed94 --- /dev/null +++ b/lib/RoadizCoreBundle/translations/core/messages.ru.xlf @@ -0,0 +1,158 @@ + + + + + + + welcome.user.email.%site% + для вас была создана учётная запись на сайте %site% + + + your.user.account + ваша учетная запись + + + username + Имя пользователя + + + email + Электронная почта + + + password + пароль + + + for.questions.email + Остались вопросы? Отправьте нам письмо: + + + + contact.form + Контактная информация + + + new.contact.form.%site% + Новая контактная форма %site% + + + form.successfully.sent + Ваш запрос был успешно отправлен + + + form.has.errors + В форме допущены ошибки + + + %field%.is.mandatory + Поле %field% не заполнено + + + email.not.valid + Адрес электронной почты указан не верно + + + date + Дата + + + ip.address + IP адрес + + + submittedAt + оправленоВ + + + form.submit + Отправить + + + answer.form + Форма для ответа + + + customForm.%name%.send + Запрос %name% отправлен + + + new.answer.form.%site% + Новый запрос от %site% + + + file.not.accepted + Прикрепленный файл не может быть принят + + + customForm.answer.sent + Ваш овет успешно отправлен + + + none + (нет) + + + reset.password.request + Запрос на сброс пароля + + + you.asked.for.a.password.reset.on.%site% + Вы запрашивали сброс пароля на %site% + + + you.need.to.choose.a.new.password.using.following.link + Вам нужно создать новый пароль к вашей учетной записи используя ссылку указаную ниже. + + + as.a.reminder.here.are.your.credentials + В качестве напоминания, вот ваши учетные данные. + + + if.you.didnt.request.this.password.reset.ignore.this.email + Если вы не запрашивали смену пароля, просто проигнорируйте это письмо + + + reset_your_password + Сбросить пароль + + + you.need.to.fill.this.required.field + Это поле необходимо заполнить + + + *.required.fields + * Обязательное поле + + + website.is.under.maintenance + Сайт закрыт для обслуживания + + + website.is.under.maintenance.we.will.be.back.soon + Сайт закрыт на обслуживание, но скоро вернётся! + + + yaml.is.not.valid.{{ error }} + YAML-код содержит ошибки ({{ error }}). + + + value.is.already.used + Это значение уже используется. + Error message meaning that user can’t use that value for this object (eg. Object name is already used). + + + password_should_contains_at_least_one_capital_one_digit + пароль должен содержать хотя бы одну заглавную цифру + + + password_blacklisted + пароль в черном списке + + + password_should_be_at_least_{{length}}_characters_long + пароль должен содержать как минимум %length% символов + + + + diff --git a/lib/RoadizCoreBundle/translations/core/messages.sr.xlf b/lib/RoadizCoreBundle/translations/core/messages.sr.xlf new file mode 100644 index 00000000..0f773b30 --- /dev/null +++ b/lib/RoadizCoreBundle/translations/core/messages.sr.xlf @@ -0,0 +1,158 @@ + + + + + + + welcome.user.email.%site% + Ваш кориснички налог је направљен на сајту %site% + + + your.user.account + Ваш кориснички налог + + + username + Корисничко име + + + email + Електронска пошта + + + password + Лозинка + + + for.questions.email + Ако имате додатна питања, пишите нам на email: + + + + contact.form + Контакт форма + + + new.contact.form.%site% + Нови захтев за контакт за сајт %site% + + + form.successfully.sent + Формулар је успешно послат + + + form.has.errors + Формулар није добро попуњен. Молимо Вас да попуните тражена поља. + + + %field%.is.mandatory + Поље %field% је обавезно + + + email.not.valid + Ваша email адреса није валидна + + + date + Датум + + + ip.address + IP Адреса + + + submittedAt + Послато на + + + form.submit + Пошаљи + + + answer.form + Формулар за одговор. + + + customForm.%name%.send + Одговор %name% је послат + + + new.answer.form.%site% + Нови одговор са сајта %site% + + + file.not.accepted + Фајл који сте закачили није прихваћен + + + customForm.answer.sent + Ваш одговор је успешно послат + + + none + Ништа + + + reset.password.request + Захтев за промену лозинке + + + you.asked.for.a.password.reset.on.%site% + Затражили сте промену лозинке за сајт %site% + + + you.need.to.choose.a.new.password.using.following.link + Морате да креирате нову лозинку за свој налог и за то ћете користити следећи линк: + + + as.a.reminder.here.are.your.credentials + Подсећања ради, ово су Ваше акредитације: + + + if.you.didnt.request.this.password.reset.ignore.this.email + Ако нисте планирали да мењате нешто у вези овог email-а, игноришите ову поруку и лозинка неће бити промењена. + + + reset_your_password + Ресетујте своју лозинку + + + you.need.to.fill.this.required.field + Морате да попуните тражено поље. + + + *.required.fields + * Тражена поља + + + website.is.under.maintenance + У току је одржавање веб-сајта + + + website.is.under.maintenance.we.will.be.back.soon + У току је одржавање веб-сајта, ускоро ће поново радити! + + + yaml.is.not.valid.{{ error }} + YAML код је неисправан ({{ error }}). + + + value.is.already.used + Ова вредност је већ употребљена. + Error message meaning that user can’t use that value for this object (eg. Object name is already used). + + + password_should_contains_at_least_one_capital_one_digit + Лозинка мора садржати најмање једно велико слово, и барем један број + + + password_blacklisted + Лозинка коју сте предложили се често употребљава и недовољно је сигурна + + + password_should_be_at_least_{{length}}_characters_long + Лозинка мора бити дугачка најмање {{length}} карактера + + + + diff --git a/lib/RoadizCoreBundle/translations/core/messages.tr.xlf b/lib/RoadizCoreBundle/translations/core/messages.tr.xlf new file mode 100644 index 00000000..eb28e153 --- /dev/null +++ b/lib/RoadizCoreBundle/translations/core/messages.tr.xlf @@ -0,0 +1,150 @@ + + + + + + + welcome.user.email.%site% + Kullanıcı hesabınız %site% üzerinde oluşturuldu + + + your.user.account + Kullanıcı hesabınız + + + username + Kullanıcı adı + + + email + E-posta + + + password + Parola + + + for.questions.email + Sorunuz mu var? Bize e posta yollayın + + + + contact.form + İletişim formu + + + new.contact.form.%site% + %site% üzerinden yeni iletişim isteği + + + form.successfully.sent + Formunuz başarıyla gönderildi + + + form.has.errors + Formunuz geçerli değil. Lütfen zorunlu alanları doğru doldurun. + + + %field%.is.mandatory + %field% alanı zorunludur + + + email.not.valid + E postanız geçerli değil + + + date + Tarih + + + ip.address + IP adresi + + + submittedAt + %date will be first% de gönderildi + + + form.submit + Gönder + + + answer.form + Formu cevapla. + + + customForm.%name%.send + Cevabı %name% gönderdi. + + + new.answer.form.%site% + %site% formundan yeni cevap. + + + file.not.accepted + Eklenmiş dosya kabul edilmedi. + + + customForm.answer.sent + Cevabınız başarıyla gönderildi. + + + none + Yok + + + reset.password.request + Parola sıfırlama isteği + + + you.asked.for.a.password.reset.on.%site% + %site% üzerinde parola sıfırlama isteğiniz var + + + you.need.to.choose.a.new.password.using.following.link + Bağlantıyı kullanarak hesabınız için yeni parola oluşturmanız gerekiyor. + + + as.a.reminder.here.are.your.credentials + Bir hatırlatma olarak, herkese açık bilgileriniz burada. + + + if.you.didnt.request.this.password.reset.ignore.this.email + Bu e posta talebi sizden gelmemişse, görmezden gelebilirsiniz. Parolanız değişmemiş olacak. + + + reset_your_password + Parolanızı sıfırlayın + + + you.need.to.fill.this.required.field + Gerekli alanları doldurmanız gerek. + + + *.required.fields + * Zorunlu alanlar + + + website.is.under.maintenance + Web sitesi bakımda + + + website.is.under.maintenance.we.will.be.back.soon + Web sitesi şu an bakımda, yakında karşınızda olacak! + + + yaml.is.not.valid.{{ error }} + YAML kodu geçerli değil ({{ error }}) + + + value.is.already.used + Bu değer zaten kullanılıyor. + Error message meaning that user can’t use that value for this object (eg. Object name is already used). + + + password_should_be_at_least_{{length}}_characters_long + Parola en az %length% karakter uzunluğunda olmalıdır. + + + + diff --git a/lib/RoadizCoreBundle/translations/core/messages.uk.xlf b/lib/RoadizCoreBundle/translations/core/messages.uk.xlf new file mode 100644 index 00000000..d7bdbdfb --- /dev/null +++ b/lib/RoadizCoreBundle/translations/core/messages.uk.xlf @@ -0,0 +1,158 @@ + + + + + + + welcome.user.email.%site% + Обліковий запис був створений для вас на сайті %site% + + + your.user.account + Ваш обліковий запис + + + username + Ім'я користувача + + + email + Електронна пошта + + + password + Пароль + + + for.questions.email + Якісь питання? Напишіть нам лист: + + + + contact.form + Контактна інформація + + + new.contact.form.%site% + Запитується новий контакт на сайті %site% + + + form.successfully.sent + Ваш запит був успішно надісланий + + + form.has.errors + В формі допущено помилки + + + %field%.is.mandatory + Поле %field% не заповнено + + + email.not.valid + Адреса електронної пошти невірно вказана + + + date + Дата + + + ip.address + IP адреса + + + submittedAt + Відправлено до + + + form.submit + Відправити + + + answer.form + Форма для запитань + + + customForm.%name%.send + Запитання %name% відправлено + + + new.answer.form.%site% + Нове запитання від %site% + + + file.not.accepted + Прикріплений файл не може бути прийнятим + + + customForm.answer.sent + Ваше запитання успішно відправлено + + + none + немає + + + reset.password.request + Запит на скидання пароля + + + you.asked.for.a.password.reset.on.%site% + Ви запитували скидання паролю на %site% + + + you.need.to.choose.a.new.password.using.following.link + Вам потрібно створити новий пароль до вашого облікового запису, використовуючи посилання, що нижче + + + as.a.reminder.here.are.your.credentials + Як нагадування, ось воші облікові дані + + + if.you.didnt.request.this.password.reset.ignore.this.email + Якщо ви не запитували зміну пароля, просто ігноруйте цей лист + + + reset_your_password + Скинути пароль + + + you.need.to.fill.this.required.field + Це поле необхідно заповнити + + + *.required.fields + * Обов'язкове поле + + + website.is.under.maintenance + Сайт знаходиться на обслуговуванні + + + website.is.under.maintenance.we.will.be.back.soon + Сайт знаходиться на обслуговуванні та скоро повернеться! + + + yaml.is.not.valid.{{ error }} + YAML-код містить помилки ({{ error }}). + + + value.is.already.used + Це значення вже використовується. + Error message meaning that user can’t use that value for this object (eg. Object name is already used). + + + password_should_contains_at_least_one_capital_one_digit + пароль повинен містити принаймні одну велику цифру + + + password_blacklisted + пароль внесено в чорний список + + + password_should_be_at_least_{{length}}_characters_long + пароль повинен мати щонайменше {{length}} символів + + + + diff --git a/lib/RoadizCoreBundle/translations/core/messages.xlf b/lib/RoadizCoreBundle/translations/core/messages.xlf new file mode 100644 index 00000000..586b3624 --- /dev/null +++ b/lib/RoadizCoreBundle/translations/core/messages.xlf @@ -0,0 +1,171 @@ + + + + + + + welcome.user.email.%site% + + + + your.user.account + + + + username + + + + email + + + + password + + + + for.questions.email + + + + + contact.form + + + + + new.contact.form.%site% + + + + form.successfully.sent + + + + form.has.errors + + + + %field%.is.mandatory + + + + email.not.valid + + + + date + + + + ip.address + + + + submittedAt + + + + + form.submit + + + + + answer.form + + + + customForm.%name%.send + + + + new.answer.form.%site% + + + + + file.not.accepted + + + + + customForm.answer.sent + + + + none + + + + + reset.password.request + + + + you.asked.for.a.password.reset.on.%site% + + + + you.need.to.choose.a.new.password.using.following.link + + + + as.a.reminder.here.are.your.credentials + + + + if.you.didnt.request.this.password.reset.ignore.this.email + + + + reset_your_password + + + + you.need.to.fill.this.required.field + + + + *.required.fields + + + + + website.is.under.maintenance + + + + website.is.under.maintenance.we.will.be.back.soon + + + + yaml.is.not.valid.{{ error }} + + + + json.is.not.valid.{{ error }} + + + + value.is.already.used + + Error message meaning that user can’t use that value for this object (eg. Object name is already used). + + + + password_should_contains_at_least_one_capital_one_digit + + + + password_blacklisted + + + + password_should_be_at_least_{{length}}_characters_long + + + + + + diff --git a/lib/RoadizCoreBundle/translations/core/messages.zh.xlf b/lib/RoadizCoreBundle/translations/core/messages.zh.xlf new file mode 100644 index 00000000..53046eb4 --- /dev/null +++ b/lib/RoadizCoreBundle/translations/core/messages.zh.xlf @@ -0,0 +1,158 @@ + + + + + + + welcome.user.email.%site% + 您在 “%site%” 的用户帐号已创建。 + + + your.user.account + 您的用户帐号 + + + username + 用户名 + + + email + 电子邮件 + + + password + 密码 + + + for.questions.email + 有任何问题?请用电子邮件联系我们: + + + + contact.form + 联系表 + + + new.contact.form.%site% + “%site%” 的新联系请求 + + + form.successfully.sent + 您的表格已成功送出 + + + form.has.errors + 您的表格无效,请正确填写必填字段。 + + + %field%.is.mandatory + 字段 “%field%” 是必填的。 + + + email.not.valid + 电子邮件无效 + + + date + 日期 + + + ip.address + IP 地址 + + + submittedAt + 注册于 + + + form.submit + 注册 + + + answer.form + 答复表 + + + customForm.%name%.send + 答复 “%name%” 已送出。 + + + new.answer.form.%site% + 来自 “%site%” 的新答复表。 + + + file.not.accepted + 附件无法接受。 + + + customForm.answer.sent + 您的回复已成功送出。 + + + none + + + + reset.password.request + 重置密码请求 + + + you.asked.for.a.password.reset.on.%site% + 您请求重置 “%site%” 的密码 + + + you.need.to.choose.a.new.password.using.following.link + 您需要使用以下链接为您的帐号创建新密码。 + + + as.a.reminder.here.are.your.credentials + 提醒您,这是您的公开凭据。 + + + if.you.didnt.request.this.password.reset.ignore.this.email + 如果您没有请求此电子邮件,请忽略它且您的密码不会被更改。 + + + reset_your_password + 重置您的密码 + + + you.need.to.fill.this.required.field + 您需要填写此必填字段。 + + + *.required.fields + * 必填字段 + + + website.is.under.maintenance + 网站正在维护 + + + website.is.under.maintenance.we.will.be.back.soon + 网站正在维护中,很快回来! + + + yaml.is.not.valid.{{ error }} + YAML 代码无效。 {{ error }} + + + value.is.already.used + 此值已使用。 + Error message meaning that user can’t use that value for this object (eg. Object name is already used). + + + password_should_contains_at_least_one_capital_one_digit + 密码应包含至少一个大写字母和一个数字。 + + + password_blacklisted + 密码在黑名单中 + + + password_should_be_at_least_{{length}}_characters_long + 密码长度应至少有 {{length}} 位 + + + + diff --git a/lib/RoadizCoreBundle/translations/validators.ar.xlf b/lib/RoadizCoreBundle/translations/validators.ar.xlf new file mode 100644 index 00000000..7fcf2644 --- /dev/null +++ b/lib/RoadizCoreBundle/translations/validators.ar.xlf @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/lib/RoadizCoreBundle/translations/validators.de.xlf b/lib/RoadizCoreBundle/translations/validators.de.xlf new file mode 100644 index 00000000..1bd6ab7f --- /dev/null +++ b/lib/RoadizCoreBundle/translations/validators.de.xlf @@ -0,0 +1,11 @@ + + + + + + {{ value }} is not a valid email address. + {{ value }} ist keine gültige E-Mail-Adresse. + + + + diff --git a/lib/RoadizCoreBundle/translations/validators.en.xlf b/lib/RoadizCoreBundle/translations/validators.en.xlf new file mode 100644 index 00000000..62c27ea0 --- /dev/null +++ b/lib/RoadizCoreBundle/translations/validators.en.xlf @@ -0,0 +1,15 @@ + + + + + + {{ value }} is not a valid email address. + {{ value }} is not a valid email address. + + + tagName.%name%.alreadyExists + Tag %name% already exists. + + + + diff --git a/lib/RoadizCoreBundle/translations/validators.es.xlf b/lib/RoadizCoreBundle/translations/validators.es.xlf new file mode 100644 index 00000000..b98ab19c --- /dev/null +++ b/lib/RoadizCoreBundle/translations/validators.es.xlf @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/lib/RoadizCoreBundle/translations/validators.fr.xlf b/lib/RoadizCoreBundle/translations/validators.fr.xlf new file mode 100644 index 00000000..0daaf454 --- /dev/null +++ b/lib/RoadizCoreBundle/translations/validators.fr.xlf @@ -0,0 +1,15 @@ + + + + + + {{ value }} is not a valid email address. + {{ value }} n'est pas une adresse email valide. + + + tagName.%name%.alreadyExists + L'étiquette «%name%» existe déjà. + + + + diff --git a/lib/RoadizCoreBundle/translations/validators.id.xlf b/lib/RoadizCoreBundle/translations/validators.id.xlf new file mode 100644 index 00000000..8d22e146 --- /dev/null +++ b/lib/RoadizCoreBundle/translations/validators.id.xlf @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/lib/RoadizCoreBundle/translations/validators.it.xlf b/lib/RoadizCoreBundle/translations/validators.it.xlf new file mode 100644 index 00000000..aa25ca67 --- /dev/null +++ b/lib/RoadizCoreBundle/translations/validators.it.xlf @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/lib/RoadizCoreBundle/translations/validators.ru.xlf b/lib/RoadizCoreBundle/translations/validators.ru.xlf new file mode 100644 index 00000000..2871bce1 --- /dev/null +++ b/lib/RoadizCoreBundle/translations/validators.ru.xlf @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/lib/RoadizCoreBundle/translations/validators.sr.xlf b/lib/RoadizCoreBundle/translations/validators.sr.xlf new file mode 100644 index 00000000..d39ab964 --- /dev/null +++ b/lib/RoadizCoreBundle/translations/validators.sr.xlf @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/lib/RoadizCoreBundle/translations/validators.tr.xlf b/lib/RoadizCoreBundle/translations/validators.tr.xlf new file mode 100644 index 00000000..7d3ddf7b --- /dev/null +++ b/lib/RoadizCoreBundle/translations/validators.tr.xlf @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/lib/RoadizCoreBundle/translations/validators.uk.xlf b/lib/RoadizCoreBundle/translations/validators.uk.xlf new file mode 100644 index 00000000..1151700b --- /dev/null +++ b/lib/RoadizCoreBundle/translations/validators.uk.xlf @@ -0,0 +1,11 @@ + + + + + + {{ value }} is not a valid email address. + {{ value }} не є дійсною email адресою. + + + + diff --git a/lib/RoadizCoreBundle/translations/validators.xlf b/lib/RoadizCoreBundle/translations/validators.xlf new file mode 100644 index 00000000..82fbfc32 --- /dev/null +++ b/lib/RoadizCoreBundle/translations/validators.xlf @@ -0,0 +1,15 @@ + + + + + + {{ value }} is not a valid email address. + + + + tagName.%name%.alreadyExists + + + + + diff --git a/lib/RoadizCoreBundle/translations/validators.zh.xlf b/lib/RoadizCoreBundle/translations/validators.zh.xlf new file mode 100644 index 00000000..e46bb7f9 --- /dev/null +++ b/lib/RoadizCoreBundle/translations/validators.zh.xlf @@ -0,0 +1,11 @@ + + + + + + {{ value }} is not a valid email address. + {{ value }} 不是有效的电子邮件地址。 + + + + diff --git a/lib/RoadizFontBundle b/lib/RoadizFontBundle deleted file mode 160000 index 33ca5c22..00000000 --- a/lib/RoadizFontBundle +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 33ca5c2270ae1e41d331defe194c521c4ce20691 diff --git a/lib/RoadizFontBundle/.github/workflows/run-test.yml b/lib/RoadizFontBundle/.github/workflows/run-test.yml new file mode 100644 index 00000000..3b17d56e --- /dev/null +++ b/lib/RoadizFontBundle/.github/workflows/run-test.yml @@ -0,0 +1,41 @@ +name: Static analysis and code style + +on: + push: + branches: + - develop + - 'release/**' + - 'hotfix/**' + tags: ['**'] + pull_request: + types: + - opened + - reopened + - synchronize + - ready_for_review + +jobs: + static-analysis-tests: + runs-on: ubuntu-latest + strategy: + matrix: + php-version: ['8.0', '8.1'] + steps: + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + - uses: actions/checkout@v3 + - name: Cache Composer packages + id: composer-cache + uses: actions/cache@v3 + with: + path: vendor + key: ${{ runner.os }}-php-${{ matrix.php-version }}-${{ hashFiles('**/composer.lock') }} + restore-keys: | + ${{ runner.os }}-php-${{ matrix.php-version }}- + - name: Install Dependencies + run: composer install --no-scripts --no-ansi --no-interaction --no-progress + - name: Run PHP Code Sniffer + run: vendor/bin/phpcs -p ./src + - name: Run PHPStan + run: vendor/bin/phpstan analyse --no-progress -c phpstan.neon diff --git a/lib/RoadizFontBundle/.gitignore b/lib/RoadizFontBundle/.gitignore new file mode 100644 index 00000000..b8f4f62c --- /dev/null +++ b/lib/RoadizFontBundle/.gitignore @@ -0,0 +1,190 @@ + +# Created by https://www.toptal.com/developers/gitignore/api/phpstorm+all +# Edit at https://www.toptal.com/developers/gitignore?templates=phpstorm+all + +### PhpStorm+all ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# AWS User-specific +.idea/**/aws.xml + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +### PhpStorm+all Patch ### +# Ignores the whole .idea folder and all .iml files +# See https://github.com/joeblau/gitignore.io/issues/186 and https://github.com/joeblau/gitignore.io/issues/360 + +.idea/ + +# Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-249601023 + +*.iml +modules.xml +.idea/misc.xml +*.ipr + +# Sonarlint plugin +.idea/sonarlint + +# End of https://www.toptal.com/developers/gitignore/api/phpstorm+all + +###> symfony/framework-bundle ### +/.env.local +/.env.local.php +/.env.*.local +/config/secrets/prod/prod.decrypt.private.php +/public/bundles/ +/var/ +/vendor/ +###< symfony/framework-bundle ### + +###> symfony/phpunit-bridge ### +.phpunit.result.cache +/phpunit.xml +###< symfony/phpunit-bridge ### + +###> phpunit/phpunit ### +/phpunit.xml +.phpunit.result.cache +###< phpunit/phpunit ### +/lib/ +/.data/ + +###> squizlabs/php_codesniffer ### +/.phpcs-cache +/phpcs.xml +###< squizlabs/php_codesniffer ### +/report.txt +/composer.lock + +###> lexik/jwt-authentication-bundle ### +/config/jwt/*.pem +###< lexik/jwt-authentication-bundle ### +/symfony.lock + +# Created by https://www.toptal.com/developers/gitignore/api/symfony +# Edit at https://www.toptal.com/developers/gitignore?templates=symfony + +### Symfony ### +# Cache and logs (Symfony2) +/app/cache/* +/app/logs/* +!app/cache/.gitkeep +!app/logs/.gitkeep + +# Email spool folder +/app/spool/* + +# Cache, session files and logs (Symfony3) +/var/cache/* +/var/logs/* +/var/sessions/* +!var/cache/.gitkeep +!var/logs/.gitkeep +!var/sessions/.gitkeep + +# Logs (Symfony4) +/var/log/* +!var/log/.gitkeep + +# Parameters +/app/config/parameters.yml +/app/config/parameters.ini + +# Managed by Composer +/app/bootstrap.php.cache +/var/bootstrap.php.cache +/bin/* + +# Assets and user uploads +/web/bundles/ +/web/uploads/ + +# PHPUnit +/app/phpunit.xml + +# Build data +/build/ + +# Composer PHAR +/composer.phar + +# Backup entities generated with doctrine:generate:entities command +**/Entity/*~ + +# Embedded web-server pid file +/.web-server-pid + +### Symfony Patch ### +/web/css/ +/web/js/ + +# End of https://www.toptal.com/developers/gitignore/api/symfony diff --git a/lib/RoadizFontBundle/.travis.yml b/lib/RoadizFontBundle/.travis.yml new file mode 100644 index 00000000..316f0e9e --- /dev/null +++ b/lib/RoadizFontBundle/.travis.yml @@ -0,0 +1,14 @@ +language: php +php: + - '8.0' + - '8.1' + - 'nightly' +jobs: + allow_failures: + - php: 'nightly' +install: + - composer install --dev --no-scripts --no-suggest + +script: + - vendor/bin/phpcs -p ./src + - vendor/bin/phpstan analyse -c phpstan.neon diff --git a/lib/RoadizFontBundle/LICENSE.md b/lib/RoadizFontBundle/LICENSE.md new file mode 100644 index 00000000..d4d8a009 --- /dev/null +++ b/lib/RoadizFontBundle/LICENSE.md @@ -0,0 +1,9 @@ +The MIT License (MIT) + +Copyright © 2023 Ambroise Maupate + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/lib/RoadizFontBundle/Makefile b/lib/RoadizFontBundle/Makefile new file mode 100644 index 00000000..16ef8148 --- /dev/null +++ b/lib/RoadizFontBundle/Makefile @@ -0,0 +1,3 @@ +test: + php -d "memory_limit=-1" vendor/bin/phpcbf --report=full --report-file=./report.txt -p ./src + php -d "memory_limit=-1" vendor/bin/phpstan analyse -c phpstan.neon diff --git a/lib/RoadizFontBundle/README.md b/lib/RoadizFontBundle/README.md new file mode 100644 index 00000000..da388ca6 --- /dev/null +++ b/lib/RoadizFontBundle/README.md @@ -0,0 +1,92 @@ +# Roadiz Font bundle + +![Run test status](https://github.com/roadiz/font-bundle/actions/workflows/run-test.yml/badge.svg?branch=develop) + +Installation +============ + +Make sure Composer is installed globally, as explained in the +[installation chapter](https://getcomposer.org/doc/00-intro.md) +of the Composer documentation. + +Applications that use Symfony Flex +---------------------------------- + +Open a command console, enter your project directory and execute: + +```console +$ composer require roadiz/font-bundle +``` + +Applications that don't use Symfony Flex +---------------------------------------- + +### Step 1: Download the Bundle + +Open a command console, enter your project directory and execute the +following command to download the latest stable version of this bundle: + +```console +$ composer require roadiz/font-bundle +``` + +### Step 2: Enable the Bundle + +Then, enable the bundle by adding it to the list of registered bundles +in the `config/bundles.php` file of your project: + +```php +// config/bundles.php + +return [ + // ... + \RZ\Roadiz\FontBundle\RoadizFontBundle::class => ['all' => true], +]; +``` + +## Configuration + +- Create folders: `var/files/fonts` for fonts storage +- Add Flysystem storage definition +```yaml +# config/packages/flysystem.yaml +flysystem: + storages: + font.storage: + adapter: 'local' + options: + directory: '%kernel.project_dir%/var/files/fonts' +``` +- Copy and merge `@RoadizFontBundle/config/packages/*` files into your project `config/packages` folder +```yaml +# config/routes.yaml +roadiz_font: + resource: "@RoadizFontBundle/config/routing.yaml" +``` +- Add bundle to doctrine entity mapping +```yaml +doctrine: + orm: + mappings: + RoadizFontBundle: + is_bundle: true + type: attribute + dir: 'src/Entity' + prefix: 'RZ\Roadiz\FontBundle\Entity' + alias: RoadizFontBundle +``` +- Create a new Roadiz role: `ROLE_ACCESS_FONTS` +- Add new `roadiz_rozier` admin sub-entry +```yaml +--- +roadiz_rozier: + entries: + construction: + subentries: + manage_fonts: + name: 'manage.fonts' + route: fontsHomePage + icon: 'uk-icon-rz-fontes' + roles: ['ROLE_ACCESS_FONTS'] +``` +- Perform *Doctrine Migrations* to create `fonts` table diff --git a/lib/RoadizFontBundle/composer.json b/lib/RoadizFontBundle/composer.json new file mode 100644 index 00000000..ea4bc5b2 --- /dev/null +++ b/lib/RoadizFontBundle/composer.json @@ -0,0 +1,94 @@ +{ + "name": "roadiz/font-bundle", + "license": "MIT", + "keywords": [ + "cms", + "backoffice", + "rezo zero" + ], + "authors": [ + { + "name": "Ambroise Maupate", + "email": "ambroise@roadiz.io", + "homepage": "https://www.roadiz.io", + "role": "Lead developer" + } + ], + "type": "symfony-bundle", + "minimum-stability": "dev", + "prefer-stable": true, + "require": { + "php": ">=8.0", + "doctrine/annotations": "^1.0", + "doctrine/doctrine-bundle": "^2.3", + "doctrine/doctrine-migrations-bundle": "^3.1", + "doctrine/orm": "^2.14.1", + "jms/serializer": "^3.9.0", + "league/flysystem": "^3.0", + "roadiz/models": "2.1.x-dev", + "roadiz/rozier": "2.1.x-dev", + "sensio/framework-extra-bundle": "^6.1", + "symfony/asset": "5.4.*", + "symfony/cache": "5.4.*", + "symfony/dotenv": "5.4.*", + "symfony/expression-language": "5.4.*", + "symfony/form": "5.4.*", + "symfony/framework-bundle": "5.4.*", + "symfony/http-client": "5.4.*", + "symfony/intl": "5.4.*", + "symfony/runtime": "5.4.*", + "symfony/string": "5.4.*", + "symfony/translation": "5.4.*", + "symfony/twig-bundle": "5.4.*", + "symfony/validator": "5.4.*", + "symfony/yaml": "5.4.*", + "twig/extra-bundle": "^2.12|^3.0", + "twig/intl-extra": "*", + "twig/string-extra": "*", + "twig/twig": "^3.1" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.4", + "phpstan/phpstan": "^1.5.3", + "phpstan/phpstan-doctrine": "^1.3", + "phpunit/phpunit": "^9.5", + "squizlabs/php_codesniffer": "^3.5", + "symfony/browser-kit": "5.4.*", + "symfony/phpunit-bridge": "5.4.*", + "symfony/stopwatch": "5.4.*", + "roadiz/core-bundle": "2.1.x-dev", + "roadiz/compat-bundle": "2.1.x-dev", + "roadiz/rozier-bundle": "2.1.x-dev", + "roadiz/documents": "2.1.x-dev", + "roadiz/entity-generator": "2.1.x-dev" + }, + "config": { + "optimize-autoloader": true, + "preferred-install": { + "*": "dist" + }, + "sort-packages": true, + "allow-plugins": { + "symfony/flex": false, + "symfony/runtime": false, + "php-http/discovery": false + } + }, + "autoload": { + "psr-4": { + "RZ\\Roadiz\\FontBundle\\": "src/" + } + }, + "scripts": { + "auto-scripts": { + "cache:clear": "symfony-cmd", + "assets:install %PUBLIC_DIR%": "symfony-cmd" + } + }, + "extra": { + "branch-alias": { + "dev-main": "2.0.x-dev", + "dev-develop": "2.1.x-dev" + } + } +} diff --git a/lib/RoadizFontBundle/config/fixtures.yaml b/lib/RoadizFontBundle/config/fixtures.yaml new file mode 100644 index 00000000..0b16707e --- /dev/null +++ b/lib/RoadizFontBundle/config/fixtures.yaml @@ -0,0 +1,6 @@ +importFiles: + roles: + - fixtures/roles.json + groups: [] + settings: [] + nodetypes: [] diff --git a/lib/RoadizFontBundle/config/fixtures/roles.json b/lib/RoadizFontBundle/config/fixtures/roles.json new file mode 100644 index 00000000..282bb810 --- /dev/null +++ b/lib/RoadizFontBundle/config/fixtures/roles.json @@ -0,0 +1,6 @@ +[ + { + "name": "ROLE_ACCESS_FONTS", + "groups": [] + } +] diff --git a/lib/RoadizFontBundle/config/packages/doctrine.yaml b/lib/RoadizFontBundle/config/packages/doctrine.yaml new file mode 100644 index 00000000..b7fc4638 --- /dev/null +++ b/lib/RoadizFontBundle/config/packages/doctrine.yaml @@ -0,0 +1,9 @@ +doctrine: + orm: + mappings: + RoadizFontBundle: + is_bundle: true + type: attribute + dir: 'src/Entity' + prefix: 'RZ\Roadiz\FontBundle\Entity' + alias: RoadizFontBundle diff --git a/lib/RoadizFontBundle/config/packages/flysystem.yaml b/lib/RoadizFontBundle/config/packages/flysystem.yaml new file mode 100644 index 00000000..06718fba --- /dev/null +++ b/lib/RoadizFontBundle/config/packages/flysystem.yaml @@ -0,0 +1,7 @@ +# Read the documentation at https://github.com/thephpleague/flysystem-bundle/blob/master/docs/1-getting-started.md +flysystem: + storages: + font.storage: + adapter: 'local' + options: + directory: '%kernel.project_dir%/var/files/fonts' diff --git a/lib/RoadizFontBundle/config/routing.yaml b/lib/RoadizFontBundle/config/routing.yaml new file mode 100644 index 00000000..1e4125dc --- /dev/null +++ b/lib/RoadizFontBundle/config/routing.yaml @@ -0,0 +1,41 @@ +# +# Fonts serving +# +FontFile: + path: /fonts/files/{filename}_{variant}.{extension} + controller: RZ\Roadiz\FontBundle\Controller\FontFaceController::fontFileAction + requirements: + filename: "[a-zA-Z0-9\\-_]+" + variant: "[0-9]+" + extension: "[a-z0-9]+" + +FontFaceCSS: + path: /fonts/font-faces.css + controller: RZ\Roadiz\FontBundle\Controller\FontFaceController::fontFacesAction + +# FONTS +fontsHomePage: + path: /rz-admin/fonts + defaults: + _controller: RZ\Roadiz\FontBundle\Controller\Admin\FontsController::defaultAction + +fontsEditPage: + path: /rz-admin/fonts/edit/{id} + defaults: + _controller: RZ\Roadiz\FontBundle\Controller\Admin\FontsController::editAction + requirements: { id : "[0-9]+" } +fontsDownloadPage: + path: /rz-admin/fonts/download/{id} + defaults: + _controller: RZ\Roadiz\FontBundle\Controller\Admin\FontsController::downloadAction + requirements: { id : "[0-9]+" } +fontsAddPage: + path: /rz-admin/fonts/add + defaults: + _controller: RZ\Roadiz\FontBundle\Controller\Admin\FontsController::addAction + requirements: { id : "[0-9]+" } +fontsDeletePage: + path: /rz-admin/fonts/delete/{id} + defaults: + _controller: RZ\Roadiz\FontBundle\Controller\Admin\FontsController::deleteAction + requirements: { id : "[0-9]+" } diff --git a/lib/RoadizFontBundle/config/services.yaml b/lib/RoadizFontBundle/config/services.yaml new file mode 100644 index 00000000..2f0a1c6d --- /dev/null +++ b/lib/RoadizFontBundle/config/services.yaml @@ -0,0 +1,27 @@ +--- +services: + # default configuration for services in *this* file + _defaults: + autowire: true # Automatically injects dependencies in your services. + autoconfigure: true # Automatically registers your services as commands, event subscribers, etc. + bind: {} + + RZ\Roadiz\FontBundle\: + resource: '../src/' + exclude: + - '../src/DependencyInjection/' + - '../src/Entity/' + - '../src/Traits/' + - '../src/Kernel.php' + - '../src/Tests/' + - '../src/Event/' + + RZ\Roadiz\FontBundle\Controller\: + resource: '../src/Controller' + tags: [ 'controller.service_arguments' ] + + RZ\Roadiz\FontBundle\Doctrine\EventSubscriber\: + resource: '../src/Doctrine/EventSubscriber' + tags: + - { name: monolog.logger, channel: doctrine } + - { name: doctrine.event_subscriber } diff --git a/lib/RoadizFontBundle/migrations/Version20221015083114.php b/lib/RoadizFontBundle/migrations/Version20221015083114.php new file mode 100644 index 00000000..d3d138b7 --- /dev/null +++ b/lib/RoadizFontBundle/migrations/Version20221015083114.php @@ -0,0 +1,36 @@ +addSql('CREATE TABLE IF NOT EXISTS fonts (id INT AUTO_INCREMENT NOT NULL, variant INT NOT NULL, eot_filename VARCHAR(255) DEFAULT NULL, woff_filename VARCHAR(255) DEFAULT NULL, woff2_filename VARCHAR(255) DEFAULT NULL, otf_filename VARCHAR(255) DEFAULT NULL, svg_filename VARCHAR(255) DEFAULT NULL, name VARCHAR(255) NOT NULL, hash VARCHAR(255) NOT NULL, folder VARCHAR(255) NOT NULL, description LONGTEXT DEFAULT NULL, created_at DATETIME DEFAULT NULL, updated_at DATETIME DEFAULT NULL, INDEX IDX_7303E8FB8B8E8428 (created_at), INDEX IDX_7303E8FB43625D9F (updated_at), UNIQUE INDEX UNIQ_7303E8FB5E237E06F143BFAD (name, variant), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('DROP TABLE fonts'); + } + + public function isTransactional(): bool + { + return false; + } +} diff --git a/lib/RoadizFontBundle/phpcs.xml.dist b/lib/RoadizFontBundle/phpcs.xml.dist new file mode 100644 index 00000000..19bff0cc --- /dev/null +++ b/lib/RoadizFontBundle/phpcs.xml.dist @@ -0,0 +1,13 @@ + + + + + + + + + + + src/ + diff --git a/lib/RoadizFontBundle/phpstan.neon b/lib/RoadizFontBundle/phpstan.neon new file mode 100644 index 00000000..80dda276 --- /dev/null +++ b/lib/RoadizFontBundle/phpstan.neon @@ -0,0 +1,32 @@ +parameters: + level: 5 + paths: + - src + excludePaths: + - */node_modules/* + - */bower_components/* + - */static/* + ignoreErrors: + - '#Call to an undefined method RZ\\Roadiz\\CoreBundle\\Repository#' + - '#Call to an undefined method RZ\\Roadiz\\UserBundle\\Repository#' + - '#Call to an undefined method Doctrine\\Persistence\\ObjectRepository#' + - '#Call to an undefined method Doctrine\\Persistence\\ObjectManager#' + - '#Call to an undefined method Doctrine\\ORM\\EntityRepository#' + - '#Call to an undefined method Symfony\\Component\\Config\\Definition\\Builder\\NodeDefinition::#' + - '#Access to an undefined property Symfony\\Component\\Validator\\Constraint::#' + - '#RZ\\Roadiz\\Core\\Entities\\Tag does not have a field named \$translation#' + - '#does not have a field named \$node\.home#' + - '#does not have a field named \$node\.id#' + - '#does not have a field named \$node\.parent#' + - '#does not have a field named \$translation#' + # PHPStan Doctrine does not support ResolveTargetEntityListener + - '#Property ([a-zA-Z\\\:\$]+) type mapping mismatch: property can contain ([a-zA-Z\\\&\>\<]+)Interface\>?\|null but database expects ([a-zA-Z\\\&\>\<]+)\|null#' + - '#Property ([a-zA-Z\\\:\$]+) type mapping mismatch: property can contain ([a-zA-Z\\\&\>\<]+)Interface\>? but database expects ([a-zA-Z\\\&\>\<]+)#' + - '#type mapping mismatch: database can contain array\|bool\|float\|int\|JsonSerializable\|stdClass\|string\|null but property expects array\|null#' + - '#Doctrine\\ORM\\Mapping\\GeneratedValue constructor expects#' + + reportUnmatchedIgnoredErrors: false + checkGenericClassInNonGenericObjectType: false + checkMissingIterableValueType: false +includes: + - vendor/phpstan/phpstan-doctrine/extension.neon diff --git a/lib/RoadizFontBundle/src/Controller/Admin/FontsController.php b/lib/RoadizFontBundle/src/Controller/Admin/FontsController.php new file mode 100644 index 00000000..65b99acc --- /dev/null +++ b/lib/RoadizFontBundle/src/Controller/Admin/FontsController.php @@ -0,0 +1,187 @@ +fontStorage = $fontStorage; + } + + /** + * @inheritDoc + */ + protected function supports(PersistableInterface $item): bool + { + return $item instanceof Font; + } + + /** + * @inheritDoc + */ + protected function getNamespace(): string + { + return 'font'; + } + + /** + * @inheritDoc + */ + protected function createEmptyItem(Request $request): PersistableInterface + { + return new Font(); + } + + /** + * @inheritDoc + */ + protected function getTemplateFolder(): string + { + return '@RoadizFont/admin'; + } + + /** + * @inheritDoc + */ + protected function getRequiredRole(): string + { + return 'ROLE_ACCESS_FONTS'; + } + + /** + * @inheritDoc + */ + protected function getEntityClass(): string + { + return Font::class; + } + + /** + * @inheritDoc + */ + protected function getFormType(): string + { + return FontType::class; + } + + /** + * @inheritDoc + */ + protected function getDefaultOrder(Request $request): array + { + return ['name' => 'ASC']; + } + + /** + * @inheritDoc + */ + protected function getDefaultRouteName(): string + { + return 'fontsHomePage'; + } + + /** + * @inheritDoc + */ + protected function getEditRouteName(): string + { + return 'fontsEditPage'; + } + + /** + * @inheritDoc + */ + protected function createUpdateEvent(PersistableInterface $item): ?Event + { + if ($item instanceof Font) { + return new PreUpdatedFontEvent($item); + } + return null; + } + + /** + * @inheritDoc + */ + protected function getEntityName(PersistableInterface $item): string + { + if ($item instanceof Font) { + return $item->getName(); + } + throw new \InvalidArgumentException('Item should be instance of ' . $this->getEntityClass()); + } + + /** + * Return a ZipArchive of requested font. + * + * @param Request $request + * @param int $id + * + * @return BinaryFileResponse + * @throws FilesystemException + */ + public function downloadAction(Request $request, int $id): BinaryFileResponse + { + $this->denyAccessUnlessGranted($this->getRequiredRole()); + + /** @var Font|null $font */ + $font = $this->em()->find(Font::class, $id); + + if ($font !== null) { + // Prepare File + $file = tempnam(sys_get_temp_dir(), "font_" . $font->getId()); + $zip = new \ZipArchive(); + $zip->open($file, \ZipArchive::CREATE); + + if ("" != $font->getEOTFilename()) { + $zip->addFromString($font->getEOTFilename(), $this->fontStorage->read($font->getEOTRelativeUrl())); + } + if ("" != $font->getSVGFilename()) { + $zip->addFromString($font->getSVGFilename(), $this->fontStorage->read($font->getSVGRelativeUrl())); + } + if ("" != $font->getWOFFFilename()) { + $zip->addFromString($font->getWOFFFilename(), $this->fontStorage->read($font->getWOFFRelativeUrl())); + } + if ("" != $font->getWOFF2Filename()) { + $zip->addFromString($font->getWOFF2Filename(), $this->fontStorage->read($font->getWOFF2RelativeUrl())); + } + if ("" != $font->getOTFFilename()) { + $zip->addFromString($font->getOTFFilename(), $this->fontStorage->read($font->getOTFRelativeUrl())); + } + // Close and send to users + $zip->close(); + $filename = StringHandler::slugify($font->getName() . ' ' . $font->getReadableVariant()) . '.zip'; + + return (new BinaryFileResponse($file, Response::HTTP_OK, [ + 'content-type' => 'application/zip', + 'content-disposition' => 'attachment; filename=' . $filename, + ], false))->deleteFileAfterSend(true); + } + + throw new ResourceNotFoundException(); + } +} diff --git a/lib/RoadizFontBundle/src/Controller/FontFaceController.php b/lib/RoadizFontBundle/src/Controller/FontFaceController.php new file mode 100644 index 00000000..502811be --- /dev/null +++ b/lib/RoadizFontBundle/src/Controller/FontFaceController.php @@ -0,0 +1,178 @@ +managerRegistry = $managerRegistry; + $this->templating = $templating; + $this->fontStorage = $fontStorage; + } + + private function getFontData(Font $font, string $extension): ?array + { + try { + return match ($extension) { + 'eot' => [ + $this->fontStorage->read($font->getEOTRelativeUrl()), + Font::MIME_EOT + ], + 'woff' => [ + $this->fontStorage->read($font->getWOFFRelativeUrl()), + Font::MIME_WOFF + ], + 'woff2' => [ + $this->fontStorage->read($font->getWOFF2RelativeUrl()), + Font::MIME_WOFF2 + ], + 'svg' => [ + $this->fontStorage->read($font->getSVGRelativeUrl()), + Font::MIME_SVG + ], + 'otf' => [ + $this->fontStorage->read($font->getOTFRelativeUrl()), + Font::MIME_OTF + ], + 'ttf' => [ + $this->fontStorage->read($font->getOTFRelativeUrl()), + Font::MIME_TTF + ], + default => null, + }; + } catch (FilesystemException $exception) { + return null; + } + } + + /** + * Request a single protected font file from Roadiz. + * + * @param Request $request + * @param string $filename + * @param int $variant + * @param string $extension + * + * @return Response + * @throws \Exception + */ + public function fontFileAction(Request $request, string $filename, int $variant, string $extension): Response + { + /** @var FontRepository $repository */ + $repository = $this->managerRegistry->getRepository(Font::class); + $lastMod = $repository->getLatestUpdateDate(); + /** @var Font $font */ + $font = $repository->findOneBy(['hash' => $filename, 'variant' => $variant]); + + if (null !== $font) { + [$fontData, $mime] = $this->getFontData($font, $extension); + + if (null !== $fontData) { + $response = new Response( + '', + Response::HTTP_NOT_MODIFIED, + [ + 'content-type' => $mime, + ] + ); + if (null !== $lastMod) { + $response->setCache([ + 'last_modified' => $lastMod, + 'max_age' => 60 * 60 * 48, // expires for 2 days + 'public' => true, + ]); + } + if (!$response->isNotModified($request)) { + $response->setContent($fontData); + $response->setStatusCode(Response::HTTP_OK); + $response->setEtag(md5($response->getContent())); + } + + return $response; + } + } + $msg = "Font doesn't exist " . $filename; + + return new Response( + $msg, + Response::HTTP_NOT_FOUND, + ['content-type' => 'text/html'] + ); + } + + /** + * Request the font-face CSS file listing available fonts. + * + * @param Request $request + * + * @return Response + * @throws \Exception + */ + public function fontFacesAction(Request $request): Response + { + /** @var FontRepository $repository */ + $repository = $this->managerRegistry->getRepository(Font::class); + $lastMod = $repository->getLatestUpdateDate(); + + $response = new Response( + '', + Response::HTTP_NOT_MODIFIED, + ['content-type' => 'text/css'] + ); + $cacheConfig = [ + 'max_age' => 60 * 60 * 48, // expires for 2 days + 'public' => true, + ]; + if (null !== $lastMod) { + $cacheConfig['last_modified'] = $lastMod; + } + $response->setCache($cacheConfig); + + if ($response->isNotModified($request)) { + return $response; + } + + $fonts = $repository->findAll(); + + $assignation = [ + 'fonts' => [], + ]; + /** @var Font $font */ + foreach ($fonts as $font) { + $variantHash = $font->getHash() . $font->getVariant(); + $assignation['fonts'][] = [ + 'font' => $font, + 'variantHash' => $variantHash, + ]; + } + $response->setContent( + $this->templating->render( + '@RoadizFont/fonts/fontfamily.css.twig', + $assignation + ) + ); + $response->setEtag(md5($response->getContent())); + $response->setStatusCode(Response::HTTP_OK); + + return $response; + } +} diff --git a/lib/RoadizFontBundle/src/DependencyInjection/Compiler/DoctrineMigrationCompilerPass.php b/lib/RoadizFontBundle/src/DependencyInjection/Compiler/DoctrineMigrationCompilerPass.php new file mode 100644 index 00000000..1ad28230 --- /dev/null +++ b/lib/RoadizFontBundle/src/DependencyInjection/Compiler/DoctrineMigrationCompilerPass.php @@ -0,0 +1,59 @@ +hasDefinition('doctrine.migrations.configuration')) { + $configurationDefinition = $container->getDefinition('doctrine.migrations.configuration'); + $ns = 'RZ\Roadiz\FontBundle\Migrations'; + $path = '@RoadizFontBundle/migrations'; + + $path = $this->checkIfBundleRelativePath($path, $container); + $configurationDefinition->addMethodCall('addMigrationsDirectory', [$ns, $path]); + } + } + + private function checkIfBundleRelativePath(string $path, ContainerBuilder $container): string + { + if (isset($path[0]) && $path[0] === '@') { + $pathParts = explode('/', $path); + $bundleName = substr($pathParts[0], 1); + + $bundlePath = $this->getBundlePath($bundleName, $container); + + return $bundlePath . substr($path, strlen('@' . $bundleName)); + } + + return $path; + } + + private function getBundlePath(string $bundleName, ContainerBuilder $container): string + { + $bundleMetadata = $container->getParameter('kernel.bundles_metadata'); + assert(is_array($bundleMetadata)); + + if (! isset($bundleMetadata[$bundleName])) { + throw new RuntimeException( + sprintf( + 'The bundle "%s" has not been registered, available bundles: %s', + $bundleName, + implode(', ', array_keys($bundleMetadata)) + ) + ); + } + + return $bundleMetadata[$bundleName]['path']; + } +} diff --git a/lib/RoadizFontBundle/src/DependencyInjection/RoadizFontExtension.php b/lib/RoadizFontBundle/src/DependencyInjection/RoadizFontExtension.php new file mode 100644 index 00000000..256a0d6e --- /dev/null +++ b/lib/RoadizFontBundle/src/DependencyInjection/RoadizFontExtension.php @@ -0,0 +1,27 @@ +load('services.yaml'); + } +} diff --git a/lib/RoadizFontBundle/src/Doctrine/EventSubscriber/FontLifeCycleSubscriber.php b/lib/RoadizFontBundle/src/Doctrine/EventSubscriber/FontLifeCycleSubscriber.php new file mode 100644 index 00000000..8e8848a1 --- /dev/null +++ b/lib/RoadizFontBundle/src/Doctrine/EventSubscriber/FontLifeCycleSubscriber.php @@ -0,0 +1,171 @@ +logger = $logger; + $this->fontStorage = $fontStorage; + } + + /** + * {@inheritdoc} + */ + public function getSubscribedEvents(): array + { + return [ + Events::prePersist, + Events::preUpdate, + Events::preRemove, + Events::postPersist, + Events::postUpdate, + ]; + } + + /** + * @param LifecycleEventArgs $args + */ + public function prePersist(LifecycleEventArgs $args): void + { + $entity = $args->getObject(); + // perhaps you only want to act on some "Font" entity + if ($entity instanceof Font) { + $this->setFontFilesNames($entity); + } + } + + /** + * @param LifecycleEventArgs $args + */ + public function preUpdate(LifecycleEventArgs $args): void + { + $entity = $args->getObject(); + // perhaps you only want to act on some "Font" entity + if ($entity instanceof Font) { + $this->setFontFilesNames($entity); + } + } + + /** + * @param LifecycleEventArgs $args + * @throws FilesystemException + */ + public function postPersist(LifecycleEventArgs $args): void + { + $entity = $args->getObject(); + // perhaps you only want to act on some "Font" entity + if ($entity instanceof Font) { + $this->upload($entity); + } + } + + /** + * @param LifecycleEventArgs $args + * @throws FilesystemException + */ + public function postUpdate(LifecycleEventArgs $args): void + { + $entity = $args->getObject(); + // perhaps you only want to act on some "Font" entity + if ($entity instanceof Font) { + $this->upload($entity); + } + } + + public function preRemove(LifecycleEventArgs $args): void + { + $entity = $args->getObject(); + // perhaps you only want to act on some "Product" entity + if ($entity instanceof Font) { + try { + // factorize previous code with loop + foreach (self::$formats as $format) { + $getter = 'get' . mb_strtoupper($format) . 'Filename'; + $relativeUrlGetter = 'get' . mb_strtoupper($format) . 'RelativeUrl'; + if (null !== $entity->$getter() && $this->fontStorage->fileExists($entity->$relativeUrlGetter())) { + $this->fontStorage->delete($entity->$relativeUrlGetter()); + $this->logger->info('Font file deleted', ['file' => $entity->$relativeUrlGetter()]); + } + } + + /* + * Removing font folder if empty. + */ + $fontFolder = $entity->getFolder(); + if ($this->fontStorage->directoryExists($fontFolder)) { + $dirListing = $this->fontStorage->listContents($fontFolder); + $isDirEmpty = \count($dirListing->toArray()) <= 0; + if ($isDirEmpty) { + $this->logger->info('Font folder is empty, deleting…', ['folder' => $fontFolder]); + $this->fontStorage->deleteDirectory($fontFolder); + } + } + } catch (FilesystemException $e) { + //do nothing + } + } + } + + public function setFontFilesNames(Font $font): void + { + if ($font->getHash() == "") { + $font->generateHashWithSecret('default_roadiz_secret'); + } + + foreach (self::$formats as $format) { + /** @var UploadedFile|null $file */ + $file = $font->{'get' . ucfirst($format) . 'File'}(); + if (null !== $file) { + $font->{'set' . mb_strtoupper($format) . 'Filename'}($file->getClientOriginalName()); + } + } + } + + /** + * @param Font $font + * @return void + * @throws FilesystemException + */ + public function upload(Font $font): void + { + foreach (self::$formats as $format) { + /** @var UploadedFile|null $file */ + $file = $font->{'get' . ucfirst($format) . 'File'}(); + /** @var string|null $relativeUrl */ + $relativeUrl = $font->{'get' . mb_strtoupper($format) . 'RelativeUrl'}(); + if (null !== $file && null !== $relativeUrl) { + $filename = $file->getPathname(); + $fontResource = fopen($file->getPathname(), 'r'); + if (false !== $fontResource) { + $this->fontStorage->writeStream( + $relativeUrl, + $fontResource + ); + $font->{'set' . ucfirst($format) . 'File'}(null); + fclose($fontResource); + $this->logger->info('Font file uploaded', ['file' => $relativeUrl]); + } + } + } + } +} diff --git a/lib/RoadizFontBundle/src/Entity/Font.php b/lib/RoadizFontBundle/src/Entity/Font.php new file mode 100644 index 00000000..877aa09f --- /dev/null +++ b/lib/RoadizFontBundle/src/Entity/Font.php @@ -0,0 +1,597 @@ + 'font_variant.thin', // 100 + Font::THIN_ITALIC => 'font_variant.thin.italic', // 100 + Font::EXTRA_LIGHT => 'font_variant.extra_light', // 200 + Font::EXTRA_LIGHT_ITALIC => 'font_variant.extra_light.italic', // 200 + Font::LIGHT => 'font_variant.light', // 300 + Font::LIGHT_ITALIC => 'font_variant.light.italic', // 300 + Font::REGULAR => 'font_variant.regular', // 400 + Font::ITALIC => 'font_variant.italic', // 400 + Font::MEDIUM => 'font_variant.medium', // 500 + Font::MEDIUM_ITALIC => 'font_variant.medium.italic', // 500 + Font::SEMI_BOLD => 'font_variant.semi_bold', // 600 + Font::SEMI_BOLD_ITALIC => 'font_variant.semi_bold.italic', // 600 + Font::BOLD => 'font_variant.bold', // 700 + Font::BOLD_ITALIC => 'font_variant.bold.italic', // 700 + Font::EXTRA_BOLD => 'font_variant.extra_bold', // 800 + Font::EXTRA_BOLD_ITALIC => 'font_variant.extra_bold.italic', // 800 + Font::BLACK => 'font_variant.black', // 900 + Font::BLACK_ITALIC => 'font_variant.black.italic', // 900 + ]; + + #[ORM\Column(name: 'variant', type: 'integer', unique: false, nullable: false)] + protected int $variant = Font::REGULAR; + + protected ?UploadedFile $eotFile = null; + protected ?UploadedFile $woffFile = null; + protected ?UploadedFile $woff2File = null; + protected ?UploadedFile $otfFile = null; + protected ?UploadedFile $svgFile = null; + + #[ORM\Column(name: 'eot_filename', type: 'string', nullable: true)] + private ?string $eotFilename = null; + + #[ORM\Column(name: 'woff_filename', type: 'string', nullable: true)] + private ?string $woffFilename = null; + + #[ORM\Column(name: 'woff2_filename', type: 'string', nullable: true)] + private ?string $woff2Filename = null; + + #[ORM\Column(name: 'otf_filename', type: 'string', nullable: true)] + private ?string $otfFilename = null; + + #[ORM\Column(name: 'svg_filename', type: 'string', nullable: true)] + private ?string $svgFilename = null; + + #[ORM\Column(type: 'string', unique: false, nullable: false)] + #[Assert\NotNull] + #[Assert\NotBlank] + #[Assert\Length(max: 100)] + private string $name = ''; + + #[ORM\Column(type: 'string', unique: false, nullable: false)] + private string $hash = ''; + + #[ORM\Column(type: 'string', nullable: false)] + private string $folder = ''; + + #[ORM\Column(type: 'text', nullable: true)] + private ?string $description = null; + + /** + * Create a new Font and generate a random folder name. + */ + public function __construct() + { + $this->folder = substr(hash("crc32b", date('YmdHi')), 0, 12); + $this->initAbstractDateTimed(); + } + + /** + * Get a readable string to describe current font variant. + * + * @return string + */ + public function getReadableVariant(): string + { + return static::$variantToHuman[$this->getVariant()]; + } + + /** + * @return int + */ + public function getVariant(): int + { + return $this->variant; + } + + /** + * @param int $variant + * + * @return $this + */ + public function setVariant(int $variant): Font + { + $this->variant = $variant; + return $this; + } + + /** + * Return font variant information for CSS font-face + * into a simple array. + * + * * style + * * weight + * + * @see https://developer.mozilla.org/fr/docs/Web/CSS/font-weight + * @return array + */ + public function getFontVariantInfos(): array + { + switch ($this->getVariant()) { + case static::SEMI_BOLD_ITALIC: + return [ + 'style' => 'italic', + 'weight' => 600, + ]; + + case static::SEMI_BOLD: + return [ + 'style' => 'normal', + 'weight' => 600, + ]; + + case static::EXTRA_BOLD_ITALIC: + return [ + 'style' => 'italic', + 'weight' => 800, + ]; + + case static::EXTRA_BOLD: + return [ + 'style' => 'normal', + 'weight' => 800, + ]; + + case static::EXTRA_LIGHT_ITALIC: + return [ + 'style' => 'italic', + 'weight' => 200, + ]; + + case static::EXTRA_LIGHT: + return [ + 'style' => 'normal', + 'weight' => 200, + ]; + + case static::THIN_ITALIC: + return [ + 'style' => 'italic', + 'weight' => 100, + ]; + + case static::THIN: + return [ + 'style' => 'normal', + 'weight' => 100, + ]; + + case static::BLACK_ITALIC: + return [ + 'style' => 'italic', + 'weight' => 900, + ]; + + case static::BLACK: + return [ + 'style' => 'normal', + 'weight' => 900, + ]; + + case static::MEDIUM_ITALIC: + return [ + 'style' => 'italic', + 'weight' => 500, + ]; + + case static::MEDIUM: + return [ + 'style' => 'normal', + 'weight' => 500, + ]; + + case static::LIGHT_ITALIC: + return [ + 'style' => 'italic', + 'weight' => 300, + ]; + + case static::LIGHT: + return [ + 'style' => 'normal', + 'weight' => 300, + ]; + + case static::BOLD_ITALIC: + return [ + 'style' => 'italic', + 'weight' => 'bold', + ]; + + case static::BOLD: + return [ + 'style' => 'normal', + 'weight' => 'bold', + ]; + + case static::ITALIC: + return [ + 'style' => 'italic', + 'weight' => 'normal', + ]; + + case static::REGULAR: + default: + return [ + 'style' => 'normal', + 'weight' => 'normal', + ]; + } + } + + /** + * @return string + */ + public function getName(): string + { + return $this->name; + } + + /** + * @param string $name + * @return $this + */ + public function setName(string $name): Font + { + $this->name = $name; + return $this; + } + + /** + * @return string + */ + public function getHash(): string + { + return $this->hash; + } + + /** + * @param string $hash + * + * @return $this + */ + public function setHash(string $hash): Font + { + $this->hash = $hash; + + return $this; + } + + /** + * @param string $secret + * @return $this + */ + public function generateHashWithSecret(string $secret): Font + { + $this->hash = substr(hash("crc32b", $this->name . $secret), 0, 12); + + return $this; + } + + /** + * @return string|null + */ + public function getEOTRelativeUrl(): ?string + { + return $this->getFolder() . '/' . $this->getEOTFilename(); + } + + /** + * @return string + */ + public function getFolder(): string + { + return $this->folder; + } + + /** + * @return string|null + */ + public function getEOTFilename(): ?string + { + return $this->eotFilename; + } + + /** + * @param string|null $eotFilename + * @return $this + */ + public function setEOTFilename(?string $eotFilename): Font + { + $this->eotFilename = StringHandler::cleanForFilename($eotFilename); + return $this; + } + + /** + * @return string|null + */ + public function getWOFFRelativeUrl(): ?string + { + return $this->getFolder() . '/' . $this->getWOFFFilename(); + } + + /** + * @return string|null + */ + public function getWOFFFilename(): ?string + { + return $this->woffFilename; + } + + /** + * @param string|null $woffFilename + * @return $this + */ + public function setWOFFFilename(?string $woffFilename): Font + { + $this->woffFilename = StringHandler::cleanForFilename($woffFilename); + return $this; + } + + /** + * @return string|null + */ + public function getWOFF2RelativeUrl(): ?string + { + return $this->getFolder() . '/' . $this->getWOFF2Filename(); + } + + /** + * @return string|null + */ + public function getWOFF2Filename(): ?string + { + return $this->woff2Filename; + } + + /** + * @param string|null $woff2Filename + * + * @return $this + */ + public function setWOFF2Filename(?string $woff2Filename): Font + { + $this->woff2Filename = StringHandler::cleanForFilename($woff2Filename); + return $this; + } + + /** + * @return string|null + */ + public function getOTFRelativeUrl(): ?string + { + return $this->getFolder() . '/' . $this->getOTFFilename(); + } + + /** + * @return string|null + */ + public function getOTFFilename(): ?string + { + return $this->otfFilename; + } + + /** + * @param string|null $otfFilename + * @return $this + */ + public function setOTFFilename(?string $otfFilename): Font + { + $this->otfFilename = StringHandler::cleanForFilename($otfFilename); + return $this; + } + + /** + * @return string|null + */ + public function getSVGRelativeUrl(): ?string + { + return $this->getFolder() . '/' . $this->getSVGFilename(); + } + + /** + * @return string|null + */ + public function getSVGFilename(): ?string + { + return $this->svgFilename; + } + + /** + * @param string|null $svgFilename + * @return $this + */ + public function setSVGFilename(?string $svgFilename): Font + { + $this->svgFilename = StringHandler::cleanForFilename($svgFilename); + return $this; + } + + /** + * @return string|null + */ + public function getDescription(): ?string + { + return $this->description; + } + + /** + * @param string|null $description + * + * @return $this + */ + public function setDescription(?string $description): Font + { + $this->description = $description; + return $this; + } + + /** + * Gets the value of eotFile. + * + * @return UploadedFile|null + */ + public function getEotFile(): ?UploadedFile + { + return $this->eotFile; + } + + /** + * Sets the value of eotFile. + * + * @param UploadedFile|null $eotFile the eot file + * @return Font + */ + public function setEotFile(?UploadedFile $eotFile): Font + { + $this->eotFile = $eotFile; + return $this; + } + + /** + * Gets the value of woffFile. + * + * @return UploadedFile|null + */ + public function getWoffFile(): ?UploadedFile + { + return $this->woffFile; + } + + /** + * Sets the value of woffFile. + * + * @param UploadedFile|null $woffFile the woff file + * @return Font + */ + public function setWoffFile(?UploadedFile $woffFile): Font + { + $this->woffFile = $woffFile; + return $this; + } + + /** + * Gets the value of woff2File. + * + * @return UploadedFile|null + */ + public function getWoff2File(): ?UploadedFile + { + return $this->woff2File; + } + + /** + * Sets the value of woff2File. + * + * @param UploadedFile|null $woff2File the woff2 file + * @return Font + */ + public function setWoff2File(?UploadedFile $woff2File): Font + { + $this->woff2File = $woff2File; + return $this; + } + + /** + * Gets the value of otfFile. + * + * @return UploadedFile|null + */ + public function getOtfFile(): ?UploadedFile + { + return $this->otfFile; + } + + /** + * Sets the value of otfFile. + * + * @param UploadedFile|null $otfFile the otf file + * @return Font + */ + public function setOtfFile(?UploadedFile $otfFile): Font + { + $this->otfFile = $otfFile; + return $this; + } + + /** + * Gets the value of svgFile. + * + * @return UploadedFile|null + */ + public function getSvgFile(): ?UploadedFile + { + return $this->svgFile; + } + + /** + * Sets the value of svgFile. + * + * @param UploadedFile|null $svgFile the svg file + * @return Font + */ + public function setSvgFile(?UploadedFile $svgFile): Font + { + $this->svgFile = $svgFile; + return $this; + } +} diff --git a/lib/RoadizFontBundle/src/Event/Font/FontEvent.php b/lib/RoadizFontBundle/src/Event/Font/FontEvent.php new file mode 100644 index 00000000..18bc115a --- /dev/null +++ b/lib/RoadizFontBundle/src/Event/Font/FontEvent.php @@ -0,0 +1,42 @@ +font = $font; + } + + /** + * @return Font|null + */ + public function getFont(): ?Font + { + return $this->font; + } + + /** + * @param Font|null $font + * @return FontEvent + */ + public function setFont(?Font $font): FontEvent + { + $this->font = $font; + return $this; + } +} diff --git a/lib/RoadizFontBundle/src/Event/Font/PreUpdatedFontEvent.php b/lib/RoadizFontBundle/src/Event/Font/PreUpdatedFontEvent.php new file mode 100644 index 00000000..e3bf1718 --- /dev/null +++ b/lib/RoadizFontBundle/src/Event/Font/PreUpdatedFontEvent.php @@ -0,0 +1,9 @@ +fontSubscriber = $fontSubscriber; + } + + /** + * @inheritDoc + */ + public static function getSubscribedEvents(): array + { + return [ + PreUpdatedFontEvent::class => 'onPreUpdatedFont', + '\RZ\Roadiz\Core\Events\Font\PreUpdatedFontEvent' => 'onPreUpdatedFont', + ]; + } + + /** + * @throws FilesystemException + */ + public function onPreUpdatedFont(PreUpdatedFontEvent $event): void + { + $font = $event->getFont(); + if (null !== $font) { + /* + * Force updating files if uploaded + * as doctrine won't see any changes. + */ + $this->fontSubscriber->setFontFilesNames($font); + $this->fontSubscriber->upload($font); + } + } +} diff --git a/lib/RoadizFontBundle/src/Form/FontType.php b/lib/RoadizFontBundle/src/Form/FontType.php new file mode 100644 index 00000000..9c860531 --- /dev/null +++ b/lib/RoadizFontBundle/src/Form/FontType.php @@ -0,0 +1,90 @@ +add('name', TextType::class, [ + 'label' => 'font.name', + 'empty_data' => '', + 'help' => 'font_name_should_be_the_same_for_all_variants', + ]) + ->add('hash', TextType::class, [ + 'label' => 'font.cssfamily', + 'empty_data' => '', + 'help' => 'css_font_family_hash_is_automatically_generated_from_font_name', + ]) + ->add('variant', FontVariantsType::class, [ + 'label' => 'font.variant', + ]) + ->add('woffFile', FileType::class, [ + 'label' => 'font.woffFile', + 'required' => false, + 'multiple' => false, + 'constraints' => [ + new File([ + 'mimeTypes' => [ + Font::MIME_WOFF, + 'application/x-font-woff', + Font::MIME_DEFAULT, + ], + 'mimeTypesMessage' => 'file.is_not_a.valid.font.file', + ]), + ], + ]) + ->add('woff2File', FileType::class, [ + 'label' => 'font.woff2File', + 'required' => false, + 'multiple' => false, + 'constraints' => [ + new File([ + 'mimeTypes' => [ + Font::MIME_WOFF2, + Font::MIME_DEFAULT, + ], + 'mimeTypesMessage' => 'file.is_not_a.valid.font.file', + ]), + ], + ]); + } + + public function getBlockPrefix(): string + { + return 'font'; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'label' => false, + 'name' => '', + 'variant' => Font::REGULAR, + 'data_class' => Font::class, + 'attr' => [ + 'class' => 'uk-form font-form', + ], + ]); + + $resolver->setAllowedTypes('name', 'string'); + $resolver->setAllowedTypes('variant', 'integer'); + } +} diff --git a/lib/RoadizFontBundle/src/Form/FontVariantsType.php b/lib/RoadizFontBundle/src/Form/FontVariantsType.php new file mode 100644 index 00000000..4a76fe6d --- /dev/null +++ b/lib/RoadizFontBundle/src/Form/FontVariantsType.php @@ -0,0 +1,41 @@ +setDefaults([ + 'choices' => array_flip(Font::$variantToHuman), + ]); + } + /** + * {@inheritdoc} + */ + public function getParent(): ?string + { + return ChoiceType::class; + } + /** + * {@inheritdoc} + */ + public function getBlockPrefix(): string + { + return 'font_variants'; + } +} diff --git a/lib/RoadizFontBundle/src/Repository/FontRepository.php b/lib/RoadizFontBundle/src/Repository/FontRepository.php new file mode 100644 index 00000000..c1a08ec8 --- /dev/null +++ b/lib/RoadizFontBundle/src/Repository/FontRepository.php @@ -0,0 +1,37 @@ + + */ +final class FontRepository extends EntityRepository +{ + public function __construct( + ManagerRegistry $registry, + EventDispatcherInterface $dispatcher + ) { + parent::__construct($registry, Font::class, $dispatcher); + } + + /** + * @throws NonUniqueResultException + * @throws NoResultException + */ + public function getLatestUpdateDate(): ?\DateTimeInterface + { + $query = $this->_em->createQuery(' + SELECT MAX(f.updatedAt) FROM RZ\Roadiz\FontBundle\Entity\Font f'); + + return new \DateTimeImmutable($query->getSingleScalarResult()); + } +} diff --git a/lib/RoadizFontBundle/src/RoadizFontBundle.php b/lib/RoadizFontBundle/src/RoadizFontBundle.php new file mode 100644 index 00000000..f788f392 --- /dev/null +++ b/lib/RoadizFontBundle/src/RoadizFontBundle.php @@ -0,0 +1,24 @@ +addCompilerPass(new DoctrineMigrationCompilerPass()); + } +} diff --git a/lib/RoadizFontBundle/templates/admin/actionsMenu.html.twig b/lib/RoadizFontBundle/templates/admin/actionsMenu.html.twig new file mode 100644 index 00000000..06451bc9 --- /dev/null +++ b/lib/RoadizFontBundle/templates/admin/actionsMenu.html.twig @@ -0,0 +1,15 @@ +{% apply spaceless %} + +{% endapply %} diff --git a/lib/RoadizFontBundle/templates/admin/add.html.twig b/lib/RoadizFontBundle/templates/admin/add.html.twig new file mode 100644 index 00000000..e6602831 --- /dev/null +++ b/lib/RoadizFontBundle/templates/admin/add.html.twig @@ -0,0 +1,37 @@ +{% extends "@RoadizRozier/admin/base.html.twig" %} + +{%- block content_title -%}{% trans %}add.a.font{% endtrans %}{%- endblock -%} + +{%- block content_header_nav -%} + +{%- endblock -%} + +{%- block content_body -%} +
+ {%- block content_body_before -%}{%- endblock -%} + {% form_theme form '@RoadizRozier/forms.html.twig' %} + {{ form_start(form, { + 'attr': { + 'id': 'edit-font-form', + 'class': 'uk-form uk-form-stacked' + } + }) }}{{ form_widget(form) }} +
+ {% apply spaceless %} + + {% endapply %} +
+ {{ form_end(form) }} + {%- block content_body_after -%}{%- endblock -%} +
+ {% include '@RoadizFont/admin/actionsMenu.html.twig' %} +{%- endblock -%} diff --git a/lib/RoadizFontBundle/templates/admin/delete.html.twig b/lib/RoadizFontBundle/templates/admin/delete.html.twig new file mode 100644 index 00000000..4fac52f5 --- /dev/null +++ b/lib/RoadizFontBundle/templates/admin/delete.html.twig @@ -0,0 +1,32 @@ +{% extends "@RoadizRozier/admin/base.html.twig" %} + +{%- block content_title -%}{{- "delete.font.%name%"|trans({'%name%': item.name}) -}}{%- endblock -%} + +{%- block content_header_nav -%} + +{%- endblock -%} + +{%- block content_body -%} +
+ {% form_theme form '@RoadizRozier/forms.html.twig' %} + {{ form_start(form) }} + {{ form_widget(form) }} +
+ {% trans %}are_you_sure.delete.font{% endtrans %} + {% apply spaceless %} + {% trans %}cancel{% endtrans %} + + {% endapply %} +
+ {{ form_end(form) }} +
+{%- endblock -%} diff --git a/lib/RoadizFontBundle/templates/admin/edit.html.twig b/lib/RoadizFontBundle/templates/admin/edit.html.twig new file mode 100644 index 00000000..e7819fd1 --- /dev/null +++ b/lib/RoadizFontBundle/templates/admin/edit.html.twig @@ -0,0 +1,18 @@ +{% extends "@RoadizFont/admin/add.html.twig" %} + +{%- block content_title -%}{{- "edit.font.%name%"|trans({'%name%': item.name}) -}}{%- endblock -%} + +{%- block content_body_after -%} + +{%- endblock -%} diff --git a/lib/RoadizFontBundle/templates/admin/list.html.twig b/lib/RoadizFontBundle/templates/admin/list.html.twig new file mode 100644 index 00000000..587ee25b --- /dev/null +++ b/lib/RoadizFontBundle/templates/admin/list.html.twig @@ -0,0 +1,85 @@ +{% extends "@RoadizRozier/admin/base.html.twig" %} + +{%- block content_title -%}{% trans %}fonts{% endtrans %}{%- endblock -%} + +{%- block content_header_actions -%} + + {% trans %}add.a.font{% endtrans %} + +{%- endblock -%} + +{%- block content_filters -%} + {% include '@RoadizRozier/widgets/filtersBar.html.twig' %} +{%- endblock -%} + +{%- block content_body -%} +
+
+ + + + + + + + + + + + {% for item in items %} + + + + + + + + {% endfor %} + +
+ {% trans %}name{% endtrans %} + {% include '@RoadizRozier/includes/column_ordering.html.twig' with { + 'field': 'name', + 'filters': filters, + } only %} + + {% trans %}font.variant{% endtrans %} + {% trans %}font.files{% endtrans %} + {% trans %}font.cssfamily{% endtrans %} + {% include '@RoadizRozier/includes/column_ordering.html.twig' with { + 'field': 'hash', + 'filters': filters, + } only %} + {% trans %}actions{% endtrans %}
+ {{ item.name }} + {{ item.getReadableVariant|trans }} + {% if item.getEOTFilename %}
EOT
{% endif %} + {% if item.getWOFFFilename %}
WOFF
{% endif %} + {% if item.getWOFF2Filename %}
WOFF2
{% endif %} + {% if item.getSVGFilename %}
SVG
{% endif %} + {% if item.getOTFFilename %}
OTF
{% endif %} +
{{ item.getHash }} + {% apply spaceless %} + + + + + + + + + + {% endapply %} +
+
+
+{%- endblock -%} diff --git a/lib/RoadizFontBundle/templates/fonts/fontfamily.css.twig b/lib/RoadizFontBundle/templates/fonts/fontfamily.css.twig new file mode 100644 index 00000000..cb64b422 --- /dev/null +++ b/lib/RoadizFontBundle/templates/fonts/fontfamily.css.twig @@ -0,0 +1,37 @@ +{% for item in fonts %} +{% set font = item.font %} +{% set fontAlternatives = [] %} +{% if font.getEOTFilename %} +{% set fontAlternatives = fontAlternatives|merge([ + 'url(\'' ~ path('FontFile', {'filename': font.hash, 'variant': font.variant, 'extension': "eot"}) ~ '\') format(\'embedded-opentype\')' +]) %} +{% endif %} +{% if font.getWOFF2Filename %} +{% set fontAlternatives = fontAlternatives|merge([ + 'url(\'' ~ path('FontFile', {'filename': font.hash, 'variant': font.variant, 'extension': "woff2"}) ~ '\') format(\'woff2\')' +]) %} +{% endif %} +{% if font.getWOFFFilename %} +{% set fontAlternatives = fontAlternatives|merge([ + 'url(\'' ~ path('FontFile', {'filename': font.hash, 'variant': font.variant, 'extension': "woff"}) ~ '\') format(\'woff\')' +]) %} +{% endif %} +{% if font.getOTFFilename %} +{% set fontAlternatives = fontAlternatives|merge([ + 'url(\'' ~ path('FontFile', {'filename': font.hash, 'variant': font.variant, 'extension': "otf"}) ~ '\') format(\'opentype\')' +]) %} +{% endif %} +{% if font.getSVGFilename %} +{% set fontAlternatives = fontAlternatives|merge([ + 'url(\'' ~ path('FontFile', {'filename': font.hash, 'variant': font.variant, 'extension': "svg"}) ~ '\') format(\'svg\')' +]) %} +{% endif %} +@font-face { + font-family: "{{ font.hash }}"; + font-display: swap; + src: {{ fontAlternatives|join(', ')|raw }}; +{% for key, value in font.fontVariantInfos %} + font-{{ key }}: {{ value }}; +{% endfor %} +} +{% endfor %} diff --git a/lib/RoadizRozierBundle b/lib/RoadizRozierBundle deleted file mode 160000 index 2f5df548..00000000 --- a/lib/RoadizRozierBundle +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 2f5df548c2f9fd642f3de02beb18f7b6182466f0 diff --git a/lib/RoadizRozierBundle/.github/workflows/run-test.yml b/lib/RoadizRozierBundle/.github/workflows/run-test.yml new file mode 100644 index 00000000..3b17d56e --- /dev/null +++ b/lib/RoadizRozierBundle/.github/workflows/run-test.yml @@ -0,0 +1,41 @@ +name: Static analysis and code style + +on: + push: + branches: + - develop + - 'release/**' + - 'hotfix/**' + tags: ['**'] + pull_request: + types: + - opened + - reopened + - synchronize + - ready_for_review + +jobs: + static-analysis-tests: + runs-on: ubuntu-latest + strategy: + matrix: + php-version: ['8.0', '8.1'] + steps: + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + - uses: actions/checkout@v3 + - name: Cache Composer packages + id: composer-cache + uses: actions/cache@v3 + with: + path: vendor + key: ${{ runner.os }}-php-${{ matrix.php-version }}-${{ hashFiles('**/composer.lock') }} + restore-keys: | + ${{ runner.os }}-php-${{ matrix.php-version }}- + - name: Install Dependencies + run: composer install --no-scripts --no-ansi --no-interaction --no-progress + - name: Run PHP Code Sniffer + run: vendor/bin/phpcs -p ./src + - name: Run PHPStan + run: vendor/bin/phpstan analyse --no-progress -c phpstan.neon diff --git a/lib/RoadizRozierBundle/.gitignore b/lib/RoadizRozierBundle/.gitignore new file mode 100644 index 00000000..9994569c --- /dev/null +++ b/lib/RoadizRozierBundle/.gitignore @@ -0,0 +1,127 @@ + +# Created by https://www.toptal.com/developers/gitignore/api/phpstorm+all +# Edit at https://www.toptal.com/developers/gitignore?templates=phpstorm+all + +### PhpStorm+all ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# AWS User-specific +.idea/**/aws.xml + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +### PhpStorm+all Patch ### +# Ignores the whole .idea folder and all .iml files +# See https://github.com/joeblau/gitignore.io/issues/186 and https://github.com/joeblau/gitignore.io/issues/360 + +.idea/ + +# Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-249601023 + +*.iml +modules.xml +.idea/misc.xml +*.ipr + +# Sonarlint plugin +.idea/sonarlint + +# End of https://www.toptal.com/developers/gitignore/api/phpstorm+all + +###> symfony/framework-bundle ### +/.env.local +/.env.local.php +/.env.*.local +/config/secrets/prod/prod.decrypt.private.php +/public/bundles/ +/var/ +/vendor/ +###< symfony/framework-bundle ### + +###> symfony/phpunit-bridge ### +.phpunit.result.cache +/phpunit.xml +###< symfony/phpunit-bridge ### + +###> phpunit/phpunit ### +/phpunit.xml +.phpunit.result.cache +###< phpunit/phpunit ### +/lib/ +/.data/ + +###> squizlabs/php_codesniffer ### +/.phpcs-cache +/phpcs.xml +###< squizlabs/php_codesniffer ### +/report.txt +/composer.lock +/symfony.lock diff --git a/lib/RoadizRozierBundle/.travis.yml b/lib/RoadizRozierBundle/.travis.yml new file mode 100644 index 00000000..660b9c6f --- /dev/null +++ b/lib/RoadizRozierBundle/.travis.yml @@ -0,0 +1,16 @@ +language: php +sudo: required +php: + - 7.4 + - 8.0 + - 8.1 + - nightly +install: + - curl -s http://getcomposer.org/installer | php + - php composer.phar install --dev --no-interaction +script: + - vendor/bin/phpcs --report=full --report-file=./report.txt -p ./src + - vendor/bin/phpstan analyse -c phpstan.neon +jobs: + allow_failures: + - php: nightly diff --git a/lib/RoadizRozierBundle/CHANGELOG.md b/lib/RoadizRozierBundle/CHANGELOG.md new file mode 100644 index 00000000..9d9b6136 --- /dev/null +++ b/lib/RoadizRozierBundle/CHANGELOG.md @@ -0,0 +1,92 @@ +## 2.0.8 (2022-11-09) + +### Bug Fixes + +* Ignore Rozier vendor folder which may contain php files ([26cf7b5](https://github.com/roadiz/rozier-bundle/commit/26cf7b5018829e5fabc449476133597dcbc69747)) + +## 2.0.6 (2022-09-30) + +### Features + +* Rename ROLE_PREVIOUS_ADMIN to IS_IMPERSONATOR ([811b794](https://github.com/roadiz/rozier-bundle/commit/811b7943cec1784d665f92d7174d22d2c9474c79)) +* **Translations:** Let user choose source and destination translations ([1581cd9](https://github.com/roadiz/rozier-bundle/commit/1581cd97efc922b88380c63b5a94b4dc948a22d7)) +* **Translations:** Moved controller and form-type from Rozier to RozierBundle ([9bbf6a7](https://github.com/roadiz/rozier-bundle/commit/9bbf6a76fe919890926131e2c01652acc476cf78)) +* **Translations:** Update help messages ([2c30a29](https://github.com/roadiz/rozier-bundle/commit/2c30a29300982fbbad714921d44cfae4a6fffbf9)) + +### Bug Fixes + +* Bad merge commit ([1b697a8](https://github.com/roadiz/rozier-bundle/commit/1b697a8122bcd29cfa2418337d0b53b403cb5335)) + +## 2.0.5 (2022-09-16) + +### Bug Fixes + +* Duplicated translation key ([63872ce](https://github.com/roadiz/rozier-bundle/commit/63872ce1629e532b29bf045fc8681c71fc807932)) + +## 2.0.4 (2022-09-16) + +### Bug Fixes + +* Remove dead-code and moved most of the constraints out of Forms in favor of Entity annotations (CoreBundle) ([9787606](https://github.com/roadiz/rozier-bundle/commit/978760680d34ca267206e90efeb1fd9117ebfc04)) + +## 2.0.3 (2022-09-01) + +### Bug Fixes + +* Missing help translation for folder form type ([9983db6](https://github.com/roadiz/rozier-bundle/commit/9983db65a6a9fc8867509a342e7383efe7b8c9e3)) + +## 2.0.2 (2022-07-29) + +### Features + +* Allow editing SEO info on non-reachable nodes ([9df4675](https://github.com/roadiz/rozier-bundle/commit/9df4675febbd61405a3264c6c77768d3302bdf4f)) +* **Documents:** Add all video, audio and picture sources in document preview page ([e1c51e3](https://github.com/roadiz/rozier-bundle/commit/e1c51e34735e12b2c86ca966926fcae788bb87bb)) + +## 2.0.1 (2022-07-20) + +### Features + +* Override Rozier TranslateController to use new NodeTranslator service ([1a722c5](https://github.com/roadiz/rozier-bundle/commit/1a722c508db577845afef16f7136febd8c1685a7)) + +## 2.0.0 (2022-07-01) + +### ⚠ BREAKING CHANGES + +* `LoginRequestTrait` using Controller must implement getUserViewer() method. +* Rename `@Rozier` to `@RoadizRoadiz` + +### Features + +* Added new CustomForm usage admin section to see which nodes use custom-form ([669de0b](https://github.com/roadiz/rozier-bundle/commit/669de0b8ce962c80809c232f341d760e6b11e857)) +* Added new document limitations edit page ([8494361](https://github.com/roadiz/rozier-bundle/commit/849436120e4375ee94ec9407d607617abe2cd53d)) +* Added Realm and RealmNode admin templates and controllers ([e432bab](https://github.com/roadiz/rozier-bundle/commit/e432babe7bdb52d7e6d588e51b12b98459ad2a7e)) +* Added Realm behaviour column ([dbb03d4](https://github.com/roadiz/rozier-bundle/commit/dbb03d49f13ded11bbb079153ea4d602e63ca86d)) +* Added user list lastLogin ([83f0d85](https://github.com/roadiz/rozier-bundle/commit/83f0d854a502412dcf3dbe0025fd821cd2d725fd)) +* Added users translations messages ([13b63a4](https://github.com/roadiz/rozier-bundle/commit/13b63a43ec0148e6e6c10f2a3d39c9037a63baef)) +* Document duplicates and unused section are now in main-menu ([df2be36](https://github.com/roadiz/rozier-bundle/commit/df2be36e1642c706ab18306ec9005b0bc5c377ed)) +* EN, FR translations messages for custom-forms ([c8ddc2f](https://github.com/roadiz/rozier-bundle/commit/c8ddc2f1af52612951e9e8f9e1cd98fd66b08941)) +* Moved all OpenID logic to RoadizRozierBundle as it only supports authentication to backoffice. ([bfaa380](https://github.com/roadiz/rozier-bundle/commit/bfaa3804b5d10285200fc09542cd850f0563877e)) +* Moved SessionListFilters to RozierBundle for type compatibility issues with Aliases. ([5ca9296](https://github.com/roadiz/rozier-bundle/commit/5ca9296d2bbc20850c153c5e7b6975e2f5f2efc7)) +* Nullable discovery openId service ([e9bf89f](https://github.com/roadiz/rozier-bundle/commit/e9bf89f7a27279e21a62c44cbcd6943a45446649)) +* Nullable Profiler ([ab77abe](https://github.com/roadiz/rozier-bundle/commit/ab77abe0935dbbcd69090e130a6ff7487c5988e2)) +* Override /bulk-download route controller to use new DocumentArchiver ([79171ed](https://github.com/roadiz/rozier-bundle/commit/79171ed665162ef2cd96f09e2d93845fb31d3ec0)) +* Override DocumentTranslation form for external url ([6eece86](https://github.com/roadiz/rozier-bundle/commit/6eece8666606c94cf7b92c4a3c5070a2c8e55781)) +* Override rozier CustomFormType to implement retentionTime form ([86584fd](https://github.com/roadiz/rozier-bundle/commit/86584fd0e1de436323afc6f531fe0aab4d243079)) +* Realm EN translations ([6a08c9c](https://github.com/roadiz/rozier-bundle/commit/6a08c9c190bc146050b258520551df5be08515a7)) +* Realm FR translations ([97e7b35](https://github.com/roadiz/rozier-bundle/commit/97e7b3502fc371c74b4111d8e5469e40090341ba)) +* Refactored PingController to avoid profiler ([7ed7da2](https://github.com/roadiz/rozier-bundle/commit/7ed7da2aa21bee6015d6ad53f3a1cd76501fe214)) +* Rename @Rozier to @RoadizRoadiz to share the same Twig namespace ([23a31cb](https://github.com/roadiz/rozier-bundle/commit/23a31cb38265b5d37ff4139f4a46b2cd846764c5)) +* Rewrote LoginRequestController ([ff8e0e2](https://github.com/roadiz/rozier-bundle/commit/ff8e0e2c7d334868842d6404a82cd8489f227da4)) + + +### Bug Fixes + +* Dependencies constraints ([e2fa667](https://github.com/roadiz/rozier-bundle/commit/e2fa6678c76f44eda7934dc1c68ff2eb52300469)) +* Fixed login error translation ([f5cb8fd](https://github.com/roadiz/rozier-bundle/commit/f5cb8fdc547aa43602a84e47bcc54ad06247f3c6)) +* Login placeholder and label translation ([4ea29a9](https://github.com/roadiz/rozier-bundle/commit/4ea29a916ef81ec035b1cb42b768892e62c54db5)) +* Missing folders/users/settings explorer services ([99c32cf](https://github.com/roadiz/rozier-bundle/commit/99c32cf92fd5a2f5146270cbd41f4c5002f6964e)) +* Never connected users ([62d0c1a](https://github.com/roadiz/rozier-bundle/commit/62d0c1a819fd067360c35d407f4ee7bb0db9f5f4)) +* provide themeService in login page ([3c7ea8e](https://github.com/roadiz/rozier-bundle/commit/3c7ea8ea5955e52dc2cc682b075520460aa3288b)) +* Redirect to home page if access denied ([d65ce71](https://github.com/roadiz/rozier-bundle/commit/d65ce71700925fb741782700d951fb2d280d26c6)) +* Removed dead node export JSON pages ([14d969e](https://github.com/roadiz/rozier-bundle/commit/14d969e0261c1581d7e41b7cc7db1623671b106c)) + diff --git a/lib/RoadizRozierBundle/LICENSE.md b/lib/RoadizRozierBundle/LICENSE.md new file mode 100644 index 00000000..747e48b2 --- /dev/null +++ b/lib/RoadizRozierBundle/LICENSE.md @@ -0,0 +1,9 @@ +The MIT License (MIT) + +Copyright © 2023 Ambroise Maupate, Julien Blanchet + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/lib/RoadizRozierBundle/Makefile b/lib/RoadizRozierBundle/Makefile new file mode 100644 index 00000000..16ef8148 --- /dev/null +++ b/lib/RoadizRozierBundle/Makefile @@ -0,0 +1,3 @@ +test: + php -d "memory_limit=-1" vendor/bin/phpcbf --report=full --report-file=./report.txt -p ./src + php -d "memory_limit=-1" vendor/bin/phpstan analyse -c phpstan.neon diff --git a/lib/RoadizRozierBundle/README.md b/lib/RoadizRozierBundle/README.md new file mode 100644 index 00000000..38c19f4b --- /dev/null +++ b/lib/RoadizRozierBundle/README.md @@ -0,0 +1,134 @@ +# Roadiz Rozier bundle +**Legacy administration interface port to Roadiz v2** + +![Run test status](https://github.com/roadiz/rozier-bundle/actions/workflows/run-test.yml/badge.svg?branch=develop) + +Installation +============ + +Make sure Composer is installed globally, as explained in the +[installation chapter](https://getcomposer.org/doc/00-intro.md) +of the Composer documentation. + +Applications that use Symfony Flex +---------------------------------- + +Open a command console, enter your project directory and execute: + +```console +$ composer require roadiz/rozier-bundle +``` + +Applications that don't use Symfony Flex +---------------------------------------- + +### Step 1: Download the Bundle + +Open a command console, enter your project directory and execute the +following command to download the latest stable version of this bundle: + +```console +$ composer require roadiz/rozier-bundle +``` + +### Step 2: Enable the Bundle + +Then, enable the bundle by adding it to the list of registered bundles +in the `config/bundles.php` file of your project: + +```php +// config/bundles.php + +return [ + // ... + \RZ\Roadiz\RozierBundle\RoadizRozierBundle::class => ['all' => true], +]; +``` + +## Configuration + +- Copy `config/packages/roadiz_rozier.yaml` to your Symfony app `config/packages` folder. +- Disable Twig `strict_variables` +- Add custom `security` configuration: +```yaml +# config/packages/security.yaml +security: + enable_authenticator_manager: true + password_hashers: + Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: + algorithm: auto + providers: + openid_user_provider: + id: RZ\Roadiz\OpenId\Authentication\Provider\OpenIdAccountProvider + roadiz_user_provider: + entity: + class: RZ\Roadiz\CoreBundle\Entity\User + property: username + all_users: + chain: + providers: [ 'openid_user_provider', 'roadiz_user_provider' ] + + firewalls: + dev: + pattern: ^/(_(profiler|wdt)|css|images|js)/ + security: false + main: + lazy: true + provider: all_users + switch_user: { role: ROLE_SUPERADMIN, parameter: _su } + logout: + path: logoutPage + custom_authenticator: + - RZ\Roadiz\RozierBundle\Security\RozierAuthenticator + access_control: + - { path: ^/rz-admin/login, roles: IS_AUTHENTICATED_ANONYMOUSLY } + - { path: ^/rz-admin, roles: ROLE_BACKEND_USER } +``` +- Add custom routes: +```yaml +# config/routes.yaml +roadiz_rozier: + resource: "@RoadizRozierBundle/config/routing.yaml" + +rz_intervention_request: + resource: "@RZInterventionRequestBundle/Resources/config/routing.yml" + prefix: / +``` + +### OpenID + +This bundle can allow users to log in to backoffice using OpenID: + +```yaml +#config/packages/roadiz_rozier.yaml +roadiz_rozier: + #... + open_id: + # Verify User info in JWT at each login + verify_user_info: false + # Standard OpenID autodiscovery URL, required to enable OpenId login in Roadiz CMS. + discovery_url: '%env(string:OPEN_ID_DISCOVERY_URL)%' + # For public identity providers (such as Google), restrict users emails by their domain. + hosted_domain: '%env(string:OPEN_ID_HOSTED_DOMAIN)%' + # OpenID identity provider OAuth2 client ID + oauth_client_id: '%env(string:OPEN_ID_CLIENT_ID)%' + # OpenID identity provider OAuth2 client secret + oauth_client_secret: '%env(string:OPEN_ID_CLIENT_SECRET)%' + granted_roles: + - ROLE_USER + - ROLE_BACKEND_USER +``` + +Then add custom authenticator `roadiz_rozier.open_id.authenticator` to your security configuration: + + +```yaml +#config/packages/security.yaml +security: + firewalls: + main: + # ... + custom_authenticator: + - RZ\Roadiz\RozierBundle\Security\RozierAuthenticator + - roadiz_rozier.open_id.authenticator +``` diff --git a/lib/RoadizRozierBundle/composer.json b/lib/RoadizRozierBundle/composer.json new file mode 100644 index 00000000..a97d9ebd --- /dev/null +++ b/lib/RoadizRozierBundle/composer.json @@ -0,0 +1,60 @@ +{ + "name": "roadiz/rozier-bundle", + "license": "MIT", + "keywords": [ + "cms", + "backoffice", + "rezo zero" + ], + "authors": [ + { + "name": "Ambroise Maupate", + "email": "ambroise@roadiz.io", + "homepage": "https://www.roadiz.io", + "role": "Lead developer" + } + ], + "type": "symfony-bundle", + "minimum-stability": "dev", + "require": { + "php": ">=8.0", + "symfony/framework-bundle": "5.4.*", + "roadiz/core-bundle": "2.1.x-dev", + "roadiz/rozier": "2.1.x-dev", + "roadiz/compat-bundle": "2.1.x-dev", + "roadiz/openid": "2.1.x-dev" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.4", + "phpstan/phpstan": "^1.5.3", + "phpstan/phpstan-symfony": "^1.1.8", + "phpstan/phpstan-doctrine": "^1.3", + "squizlabs/php_codesniffer": "^3.5" + }, + "config": { + "optimize-autoloader": true, + "preferred-install": { + "*": "dist" + }, + "sort-packages": true, + "allow-plugins": { + "symfony/flex": false, + "symfony/runtime": false, + "php-http/discovery": false + } + }, + "autoload": { + "psr-4": { + "RZ\\Roadiz\\RozierBundle\\": "src/" + }, + "files": [ + "deprecated.php" + ] + }, + "extra": { + "branch-alias": { + "dev-main": "2.0.x-dev", + "dev-develop": "2.1.x-dev" + } + } +} diff --git a/lib/RoadizRozierBundle/config/packages/roadiz_rozier.yaml b/lib/RoadizRozierBundle/config/packages/roadiz_rozier.yaml new file mode 100644 index 00000000..463dd0ea --- /dev/null +++ b/lib/RoadizRozierBundle/config/packages/roadiz_rozier.yaml @@ -0,0 +1,199 @@ +--- +roadiz_rozier: + theme_dir: '%kernel.project_dir%/vendor/roadiz/rozier/src' + open_id: + # Verify User info in JWT at each login + verify_user_info: false + # Standard OpenID autodiscovery URL, required to enable OpenId login in Roadiz CMS. + discovery_url: '%env(string:OPEN_ID_DISCOVERY_URL)%' + # For public identity providers (such as Google), restrict users emails by their domain. + hosted_domain: '%env(string:OPEN_ID_HOSTED_DOMAIN)%' + # OpenID identity provider OAuth2 client ID + oauth_client_id: '%env(string:OPEN_ID_CLIENT_ID)%' + # OpenID identity provider OAuth2 client secret + oauth_client_secret: '%env(string:OPEN_ID_CLIENT_SECRET)%' + granted_roles: + - ROLE_USER + - ROLE_BACKEND_USER + entries: + dashboard: + name: dashboard + route: adminHomePage + icon: uk-icon-rz-dashboard + roles: ~ + subentries: ~ + + nodes: + name: nodes + route: ~ + icon: uk-icon-rz-global-nodes + roles: [ 'ROLE_ACCESS_NODES' ] + subentries: + all_nodes: + name: 'all.nodes' + route: nodesHomePage + icon: uk-icon-rz-all-nodes + roles: ~ + draft_nodes: + name: 'draft.nodes' + route: nodesHomeDraftPage + icon: uk-icon-rz-draft-nodes + roles: ~ + pending_nodes: + name: 'pending.nodes' + route: nodesHomePendingPage + icon: uk-icon-rz-pending-nodes + roles: ~ + archived_nodes: + name: 'archived.nodes' + route: nodesHomeArchivedPage + icon: uk-icon-rz-archives-nodes + roles: ~ + deleted_nodes: + name: 'deleted.nodes' + route: nodesHomeDeletedPage + icon: uk-icon-rz-deleted-nodes + roles: ~ + search_nodes: + name: 'search.nodes' + route: searchNodePage + icon: uk-icon-search + roles: ~ + + manage_documents: + name: 'manage.documents' + route: ~ + icon: uk-icon-rz-documents + roles: [ 'ROLE_ACCESS_DOCUMENTS' ] + subentries: + all_documents: + name: 'all.documents' + route: documentsHomePage + icon: uk-icon-rz-documents + roles: [ 'ROLE_ACCESS_DOCUMENTS' ] + private_documents: + name: 'private_documents' + route: documentsPrivateHomePage + icon: uk-icon-lock + roles: [ 'ROLE_ACCESS_DOCUMENTS' ] + unused_documents: + name: 'unused_documents' + route: documentsUnusedPage + icon: uk-icon-unlink + roles: [ 'ROLE_ACCESS_DOCUMENTS' ] + duplicated_documents: + name: 'duplicated_documents' + route: documentsDuplicatesPage + icon: uk-icon-files-o + roles: [ 'ROLE_ACCESS_DOCUMENTS' ] + + manage_tags: + name: 'manage.tags' + route: tagsHomePage + icon: uk-icon-rz-tags + roles: [ 'ROLE_ACCESS_TAGS' ] + + construction: + name: 'construction' + route: ~ + icon: uk-icon-rz-construction + roles: + - 'ROLE_ACCESS_NODETYPES' + - 'ROLE_ACCESS_ATTRIBUTES' + - 'ROLE_ACCESS_TRANSLATIONS' + - 'ROLE_ACCESS_THEMES' + - 'ROLE_ACCESS_FONTS' + - 'ROLE_ACCESS_REDIRECTIONS' + - 'ROLE_ACCESS_WEBHOOKS' + - 'ROLE_ACCESS_REALMS' + subentries: + manage_nodeTypes: + name: 'manage.nodeTypes' + route: nodeTypesHomePage + icon: uk-icon-rz-manage-nodes + roles: ['ROLE_ACCESS_NODETYPES'] + manage_attributes: + name: 'manage.attributes' + route: attributesHomePage + icon: uk-icon-server + roles: ['ROLE_ACCESS_ATTRIBUTES'] + manage_translations: + name: 'manage.translations' + route: translationsHomePage + icon: uk-icon-rz-translate + roles: ['ROLE_ACCESS_TRANSLATIONS'] + manage_redirections: + name: 'manage.redirections' + route: redirectionsHomePage + icon: 'uk-icon-compass' + roles: ['ROLE_ACCESS_REDIRECTIONS'] + manage_webhooks: + name: 'manage.webhooks' + route: webhooksHomePage + icon: 'uk-icon-space-shuttle' + roles: ['ROLE_ACCESS_WEBHOOKS'] + manage_realms: + name: 'manage.realms' + route: realmsHomePage + icon: 'uk-icon-user-secret' + roles: ['ROLE_ACCESS_REALMS'] + + user_system: + name: 'user.system' + route: ~ + icon: uk-icon-rz-users + roles: ['ROLE_ACCESS_USERS', 'ROLE_ACCESS_ROLES', 'ROLE_ACCESS_GROUPS'] + subentries: + manage_users: + name: 'manage.users' + route: usersHomePage + icon: uk-icon-rz-user + roles: ['ROLE_ACCESS_USERS'] + manage_roles: + name: 'manage.roles' + route: rolesHomePage + icon: uk-icon-rz-roles + roles: ['ROLE_ACCESS_ROLES'] + manage_groups: + name: 'manage.groups' + route: groupsHomePage + icon: uk-icon-rz-groups + roles: ['ROLE_ACCESS_GROUPS'] + + interactions: + name: 'interactions' + route: ~ + icon: uk-icon-rz-interactions + roles: + - 'ROLE_ACCESS_CUSTOMFORMS' + - 'ROLE_ACCESS_MANAGE_SUBSCRIBERS' + - 'ROLE_ACCESS_COMMENTS' + subentries: + manage_customForms: + name: 'manage.customForms' + route: customFormsHomePage + icon: uk-icon-rz-surveys + roles: ['ROLE_ACCESS_CUSTOMFORMS'] + + settings: + name: 'settings' + route: ~ + icon: uk-icon-rz-settings + roles: ['ROLE_ACCESS_SETTINGS'] + subentries: + all_settings: + name: 'all.settings' + route: settingsHomePage + icon: uk-icon-rz-settings-general + roles: ['ROLE_ACCESS_SETTINGS'] + setting_groups_dynamic: + # This is a special menu entry replaced by all settings groups + name: 'setting.groups.dynamic' # do not rename this + icon: uk-icon-rz-settings-general + roles: ['ROLE_ACCESS_SETTINGS'] + setting_groups: + name: 'setting.groups' + route: settingGroupsHomePage + icon: uk-icon-rz-settings-groups + roles: ['ROLE_ACCESS_SETTINGS'] + diff --git a/lib/RoadizRozierBundle/config/routing.yaml b/lib/RoadizRozierBundle/config/routing.yaml new file mode 100644 index 00000000..1e6e27ce --- /dev/null +++ b/lib/RoadizRozierBundle/config/routing.yaml @@ -0,0 +1,224 @@ +# Home page +adminHomePage: + path: /rz-admin + controller: Themes\Rozier\Controllers\DashboardController::indexAction + +loginRoutes: + prefix: /rz-admin + resource: "routing/login.yml" + +ajaxSessionMessages: + path: /rz-admin/session/messages + methods: [GET] + controller: Themes\Rozier\AjaxControllers\AjaxSessionMessages::getMessagesAction + +#Ping +ping: + path: /rz-admin/ping + methods: [GET] + controller: RZ\Roadiz\RozierBundle\Controller\PingController::indexAction + +# CACHES +deleteDoctrineCache: + path: /rz-admin/cache/delete-doctrine-cache + controller: Themes\Rozier\Controllers\CacheController::deleteDoctrineCache +deleteAssetsCache: + path: /rz-admin/cache/delete-assets-cache + controller: Themes\Rozier\Controllers\CacheController::deleteAssetsCache + +# NODES +nodesHomePage: + path: /rz-admin/nodes + controller: Themes\Rozier\Controllers\Nodes\NodesController::indexAction +nodesRoutes: + resource: "routing/nodes.yml" + prefix: /rz-admin/nodes + +## Ajax +ajaxRequestsRoutes: + resource: "routing/ajax.yml" + prefix: /rz-admin/ajax + +# Node TYPES +nodeTypesHomePage: + path: /rz-admin/node-types + controller: Themes\Rozier\Controllers\NodeTypes\NodeTypesController::indexAction +nodeTypesRoutes: + resource: "routing/node-types.yml" + prefix: /rz-admin/node-types + +# Node type FIELDS +nodeTypeFieldsRoutes: + resource: "routing/node-type-fields.yml" + prefix: /rz-admin/node-types/fields + +# SETTINGS +settingsHomePage: + path: /rz-admin/settings + controller: Themes\Rozier\Controllers\SettingsController::indexAction +settingsRoutes: + resource: "routing/settings.yml" + prefix: /rz-admin/settings + + +# SETTINGS GROUPS +settingGroupsHomePage: + path: /rz-admin/setting-groups + controller: Themes\Rozier\Controllers\SettingGroupsController::defaultAction +settingGroupsRoutes: + resource: "routing/setting-groups.yml" + prefix: /rz-admin/setting-groups + + +# TAGS +tagsHomePage: + path: /rz-admin/tags + controller: Themes\Rozier\Controllers\Tags\TagsController::indexAction +tagsRoutes: + resource: "routing/tags.yml" + prefix: /rz-admin/tags + +# USERS +usersHomePage: + path: /rz-admin/users + controller: Themes\Rozier\Controllers\Users\UsersController::indexAction +usersRoutes: + resource: "routing/users.yml" + prefix: /rz-admin/users + +# ATTRIBUTES +attributesHomePage: + path: /rz-admin/attributes + controller: Themes\Rozier\Controllers\Attributes\AttributeController::defaultAction +attributesRoutes: + resource: "routing/attributes.yml" + prefix: /rz-admin/attributes + + +# FOLDERS +foldersHomePage: + path: /rz-admin/folders + controller: Themes\Rozier\Controllers\FoldersController::indexAction +foldersRoutes: + resource: "routing/folders.yml" + prefix: /rz-admin/folders + + +# TRANSLATIONS +translationsHomePage: + path: /rz-admin/translations + controller: Themes\Rozier\Controllers\TranslationsController::indexAction +translationsRoutes: + resource: "routing/translations.yml" + prefix: /rz-admin/translations + + +# DOCUMENTS +documentsHomePage: + path: /rz-admin/documents/{folderId} + controller: RZ\Roadiz\RozierBundle\Controller\Document\DocumentPublicListController::indexAction + defaults: + folderId: null + requirements: { folderId : "[0-9]+" } +documentsPrivateHomePage: + path: /rz-admin/documents/private/{folderId} + controller: RZ\Roadiz\RozierBundle\Controller\Document\DocumentPrivateListController::indexAction + defaults: + folderId: null + requirements: { folderId : "[0-9]+" } +documentsRoutes: + resource: "routing/documents.yml" + prefix: /rz-admin/documents + + +# DOCUMENTS +redirectionsHomePage: + path: /rz-admin/redirections + controller: Themes\Rozier\Controllers\RedirectionsController::defaultAction +redirectionsRoutes: + resource: "routing/redirections.yml" + prefix: /rz-admin/redirections + + +# REALMS +realmsHomePage: + path: /rz-admin/realms + controller: RZ\Roadiz\RozierBundle\Controller\Realm\RealmController::defaultAction +realmsRoutes: + resource: "routing/realms.yml" + prefix: /rz-admin/realms + +# ROLES +rolesHomePage: + path: /rz-admin/roles + controller: Themes\Rozier\Controllers\RolesController::defaultAction +rolesRoutes: + resource: "routing/roles.yml" + prefix: /rz-admin/roles + +# GROUPS +groupsHomePage: + path: /rz-admin/groups + controller: Themes\Rozier\Controllers\GroupsController::defaultAction +groupsRoutes: + resource: "routing/groups.yml" + prefix: /rz-admin/groups + + +#LOGS +historyHomePage: + path: /rz-admin/history + controller: Themes\Rozier\Controllers\HistoryController::indexAction +historyUserPage: + path: /rz-admin/history/user/{userId} + requirements: { userId : "[0-9]+" } + controller: Themes\Rozier\Controllers\HistoryController::userAction + +# Custom Form +customFormsHomePage: + path: /rz-admin/custom-forms + controller: Themes\Rozier\Controllers\CustomForms\CustomFormsController::defaultAction +customFormsRoutes: + resource: "routing/custom-forms.yml" + prefix: /rz-admin/custom-forms + +# Custom Form Answer +customFormAnswersRoutes: + resource: "routing/custom-form-answers.yml" + prefix: /rz-admin/custom-form-answers + +# Custom Form FIELDS +customFormFieldsRoutes: + resource: "routing/custom-forms-fields.yml" + prefix: /rz-admin/custom-forms/fields + + +# SEARCH +searchNodePage: + path: /rz-admin/search + controller: Themes\Rozier\Controllers\SearchController::searchNodeAction +searchNodeSourcePage: + path: /rz-admin/search/{nodetypeId} + controller: Themes\Rozier\Controllers\SearchController::searchNodeSourceAction + requirements: { nodetypeId : "[0-9]+" } + +webhooksHomePage: + path: /rz-admin/webhooks + controller: Themes\Rozier\Controllers\WebhookController::defaultAction +webhooksRoutes: + resource: "routing/webhooks.yml" + prefix: /rz-admin/webhooks + + +# +# CSS to style with main color +# NOT SECURED ROUTES +# +cssMainColor: + path : /css/main-color.css + controller: Themes\Rozier\RozierApp::cssAction + +loginImagePage: + path: /css/login/image + controller: Themes\Rozier\Controllers\LoginController::imageAction + diff --git a/lib/RoadizRozierBundle/config/routing/ajax.yml b/lib/RoadizRozierBundle/config/routing/ajax.yml new file mode 100644 index 00000000..3d15ca31 --- /dev/null +++ b/lib/RoadizRozierBundle/config/routing/ajax.yml @@ -0,0 +1,205 @@ +## Ajax +nodeAjaxTags: + path: /node/tags/{nodeId} + methods: [GET] + controller: Themes\Rozier\AjaxControllers\AjaxNodesController::getTagsAction + format: json + requirements: { nodeId : "[0-9]+" } +nodeAjaxEdit: + path: /node/edit/{nodeId} + controller: Themes\Rozier\AjaxControllers\AjaxNodesController::editAction + format: json + requirements: { nodeId : "[0-9]+" } +searchNodesSourcesAjax: + path: /nodes-sources/search + methods: [GET] + defaults: + _controller: Themes\Rozier\AjaxControllers\AjaxSearchNodesSourcesController::searchAction + _format: json +nodesStatusesAjax: + path: /nodes/statuses + defaults: + _controller: Themes\Rozier\AjaxControllers\AjaxNodesController::statusesAction + _format: json +nodesTreeAjax: + path: /nodes/tree/{translationId} + methods: [GET] + defaults: + _controller: Themes\Rozier\AjaxControllers\AjaxNodeTreeController::getTreeAction + translationId: null + _format: json + requirements: { translationId : "[0-9]+" } +tagsTreeAjax: + path: /tags/tree + methods: [GET] + defaults: + _controller: Themes\Rozier\AjaxControllers\AjaxTagTreeController::getTreeAction + _format: json +foldersTreeAjax: + path: /folders/tree + methods: [GET] + defaults: + _controller: Themes\Rozier\AjaxControllers\AjaxFolderTreeController::getTreeAction + _format: json +nodesQuickAddAjax: + path: /nodes/add + defaults: + _controller: Themes\Rozier\AjaxControllers\AjaxNodesController::quickAddAction + _format: json +nodesAjaxExplorerPage: + path: /nodes/explore + methods: [GET] + defaults: + _controller: Themes\Rozier\AjaxControllers\AjaxNodesExplorerController::indexAction + _format: json +nodeTypesFieldAjaxEdit: + path: /node-types/fields/edit/{nodeTypeFieldId} + defaults: + _controller: Themes\Rozier\AjaxControllers\AjaxNodeTypeFieldsController::editAction + _format: json + requirements: { nodeTypeFieldId : "[0-9]+" } +nodeTypesAjaxByArray: + path: /node-types/list + methods: [GET] + defaults: + _controller: Themes\Rozier\AjaxControllers\AjaxNodeTypesController::listAction + _format: json +nodeTypesAjaxExplorer: + path: /node-types/explorer + methods: [GET] + defaults: + _controller: Themes\Rozier\AjaxControllers\AjaxNodeTypesController::indexAction + _format: json +nodesAjaxByArray: + path: /nodes/list + methods: [GET] + defaults: + _controller: Themes\Rozier\AjaxControllers\AjaxNodesExplorerController::listAction + _format: json + +# Explorer provider +providerAjaxExplorerPage: + path: /provider/explore + methods: [GET] + defaults: + _controller: Themes\Rozier\AjaxControllers\AjaxExplorerProviderController::indexAction + _format: json +providerAjaxByArray: + path: /provider/list + methods: [GET] + defaults: + _controller: Themes\Rozier\AjaxControllers\AjaxExplorerProviderController::listAction + _format: json + +# Entities +entitiesAjaxExplorerPage: + path: /entities/explore + methods: [GET] + defaults: + _controller: Themes\Rozier\AjaxControllers\AjaxEntitiesExplorerController::indexAction + _format: json +entitiesAjaxByArray: + path: /entities/list + methods: [GET] + defaults: + _controller: Themes\Rozier\AjaxControllers\AjaxEntitiesExplorerController::listAction + _format: json +# Documents +documentsAjaxExplorerPage: + path: /documents/explore + methods: [GET] + defaults: + _controller: Themes\Rozier\AjaxControllers\AjaxDocumentsExplorerController::indexAction + _format: json +documentsAjaxByArray: + path: /documents/list + methods: [GET] + defaults: + _controller: Themes\Rozier\AjaxControllers\AjaxDocumentsExplorerController::listAction + _format: json +foldersAjaxExplorerPage: + path: /folders/explore + methods: [GET] + defaults: + _controller: Themes\Rozier\AjaxControllers\AjaxFoldersExplorerController::indexAction + _format: json + +# customForms +customFormsAjaxExplorerPage: + path: /custom-forms/explore + methods: [GET] + defaults: + _controller: Themes\Rozier\AjaxControllers\AjaxCustomFormsExplorerController::indexAction + _format: json +customFormsAjaxByArray: + path: /custom-forms/list + methods: [GET] + defaults: + _controller: Themes\Rozier\AjaxControllers\AjaxCustomFormsExplorerController::listAction + _format: json +## Ajax +tagAjaxEdit: + path: /tag/edit/{tagId} + defaults: + _controller: Themes\Rozier\AjaxControllers\AjaxTagsController::editAction + _format: json + requirements: { tagId : "[0-9]+" } +tagAjaxSearch: + path: /tag/search + methods: [GET] + defaults: + _controller: Themes\Rozier\AjaxControllers\AjaxTagsController::searchAction + _format: json +tagsAjaxExplorer: + path: /tag/explore + methods: [GET] + defaults: + _controller: Themes\Rozier\AjaxControllers\AjaxTagsController::indexAction + _format: json +tagsAjaxExplorerList: + path: /tag/explore/list + methods: [GET] + defaults: + _controller: Themes\Rozier\AjaxControllers\AjaxTagsController::explorerListAction + _format: json +tagsAjaxByArray: + path: /tag/explore/array + methods: [GET] + defaults: + _controller: Themes\Rozier\AjaxControllers\AjaxTagsController::listArrayAction + _format: json +tagsAjaxCreate: + path: /tag/create + methods: [POST] + defaults: + _controller: Themes\Rozier\AjaxControllers\AjaxTagsController::createAction + _format: json +## Ajax +foldersAjaxEdit: + path: /folder/edit/{folderId} + defaults: + _controller: Themes\Rozier\AjaxControllers\AjaxFoldersController::editAction + _format: json + requirements: { tagId : "[0-9]+" } +foldersAjaxSearch: + path: /folder/search + methods: [GET] + defaults: + _controller: Themes\Rozier\AjaxControllers\AjaxFoldersController::searchAction + _format: json + +customFormFieldAjaxEdit: + path: /custom-forms/fields/edit/{customFormFieldId} + defaults: + _controller: Themes\Rozier\AjaxControllers\AjaxCustomFormFieldsController::editAction + _format: json + requirements: { customFormFieldId : "[0-9]+" } + + +## Attribute values +attributeValueAjaxEdit: + path: /attribute-values/edit/{attributeValueId} + defaults: + _controller: Themes\Rozier\AjaxControllers\AjaxAttributeValuesController::editAction + _format: json + requirements: { attributeValueId : "[0-9]+" } diff --git a/lib/RoadizRozierBundle/config/routing/attributes.yml b/lib/RoadizRozierBundle/config/routing/attributes.yml new file mode 100644 index 00000000..27f65245 --- /dev/null +++ b/lib/RoadizRozierBundle/config/routing/attributes.yml @@ -0,0 +1,43 @@ +attributesEditPage: + path: /edit/{id} + defaults: + _controller: Themes\Rozier\Controllers\Attributes\AttributeController::editAction + requirements: { id : "[0-9]+" } +attributesAddPage: + path: /add + defaults: + _controller: Themes\Rozier\Controllers\Attributes\AttributeController::addAction +attributesExportPage: + path: /export-all + defaults: + _controller: Themes\Rozier\Controllers\Attributes\AttributeController::exportAction +attributesImportPage: + path: /import + defaults: + _controller: Themes\Rozier\Controllers\Attributes\AttributeController::importAction + +attributesDeletePage: + path: /delete/{id} + defaults: + _controller: Themes\Rozier\Controllers\Attributes\AttributeController::deleteAction + requirements: { id : "[0-9]+" } + + +attributeGroupsHomePage: + path: /groups + defaults: + _controller: Themes\Rozier\Controllers\Attributes\AttributeGroupController::defaultAction +attributeGroupsAddPage: + path: /groups/add + defaults: + _controller: Themes\Rozier\Controllers\Attributes\AttributeGroupController::addAction +attributeGroupsEditPage: + path: /groups/edit/{id} + defaults: + _controller: Themes\Rozier\Controllers\Attributes\AttributeGroupController::editAction + requirements: { id : "[0-9]+" } +attributeGroupsDeletePage: + path: /groups/delete/{id} + defaults: + _controller: Themes\Rozier\Controllers\Attributes\AttributeGroupController::deleteAction + requirements: { id : "[0-9]+" } diff --git a/lib/RoadizRozierBundle/config/routing/custom-form-answers.yml b/lib/RoadizRozierBundle/config/routing/custom-form-answers.yml new file mode 100644 index 00000000..7b0a2098 --- /dev/null +++ b/lib/RoadizRozierBundle/config/routing/custom-form-answers.yml @@ -0,0 +1,17 @@ +customFormAnswersHomePage: + path: /{customFormId} + defaults: + _controller: Themes\Rozier\Controllers\CustomForms\CustomFormAnswersController::listAction + requirements: { customFormId : "[0-9]+" } + +customFormAnswersDeletePage: + path: /delete/{customFormAnswerId} + defaults: + _controller: Themes\Rozier\Controllers\CustomForms\CustomFormAnswersController::deleteAction + requirements: { customFormAnswerId : "[0-9]+" } + +customFormFieldAttributesHomePage: + path: /fields/{customFormAnswerId} + defaults: + _controller: Themes\Rozier\Controllers\CustomForms\CustomFormFieldAttributesController::listAction + requirements: { customFormAnswerId : "[0-9]+" } diff --git a/lib/RoadizRozierBundle/config/routing/custom-forms-fields.yml b/lib/RoadizRozierBundle/config/routing/custom-forms-fields.yml new file mode 100644 index 00000000..b76b73fb --- /dev/null +++ b/lib/RoadizRozierBundle/config/routing/custom-forms-fields.yml @@ -0,0 +1,20 @@ +customFormFieldsListPage: + path: /{customFormId} + defaults: + _controller: Themes\Rozier\Controllers\CustomForms\CustomFormFieldsController::listAction + requirements: { customFormId : "[0-9]+" } +customFormFieldsEditPage: + path: /edit/{customFormFieldId} + defaults: + _controller: Themes\Rozier\Controllers\CustomForms\CustomFormFieldsController::editAction + requirements: { customFormFieldId : "[0-9]+" } +customFormFieldsAddPage: + path: /add/{customFormId} + defaults: + _controller: Themes\Rozier\Controllers\CustomForms\CustomFormFieldsController::addAction + requirements: { customFormId : "[0-9]+" } +customFormFieldsDeletePage: + path: /delete/{customFormFieldId} + defaults: + _controller: Themes\Rozier\Controllers\CustomForms\CustomFormFieldsController::deleteAction + requirements: { customFormFieldId : "[0-9]+" } diff --git a/lib/RoadizRozierBundle/config/routing/custom-forms.yml b/lib/RoadizRozierBundle/config/routing/custom-forms.yml new file mode 100644 index 00000000..d2f3085a --- /dev/null +++ b/lib/RoadizRozierBundle/config/routing/custom-forms.yml @@ -0,0 +1,29 @@ +customFormsEditPage: + path: /edit/{id} + defaults: + _controller: Themes\Rozier\Controllers\CustomForms\CustomFormsController::editAction + requirements: { id : "[0-9]+" } +customFormsAddPage: + path: /add + defaults: + _controller: Themes\Rozier\Controllers\CustomForms\CustomFormsController::addAction +customFormsDeletePage: + path: /delete/{id} + defaults: + _controller: Themes\Rozier\Controllers\CustomForms\CustomFormsController::deleteAction + requirements: { id : "[0-9]+" } +customFormsExportPage: + path: /export/{id} + requirements: { id : "[0-9]+" } + defaults: + _controller: Themes\Rozier\Controllers\CustomForms\CustomFormsUtilsController::exportAction +customFormsUsagePage: + path: /usage/{id} + requirements: { id : "[0-9]+" } + defaults: + _controller: RZ\Roadiz\RozierBundle\Controller\CustomForm\CustomFormUsageController::usageAction +customFormsDuplicatePage: + path: /duplicate/{id} + requirements: { id : "[0-9]+" } + defaults: + _controller: Themes\Rozier\Controllers\CustomForms\CustomFormsUtilsController::duplicateAction diff --git a/lib/RoadizRozierBundle/config/routing/documents.yml b/lib/RoadizRozierBundle/config/routing/documents.yml new file mode 100644 index 00000000..ad0fc24f --- /dev/null +++ b/lib/RoadizRozierBundle/config/routing/documents.yml @@ -0,0 +1,79 @@ +documentsAdjustPage: + path: /adjust/{documentId} + defaults: + _controller: Themes\Rozier\Controllers\Documents\DocumentsController::adjustAction + requirements: { documentId : "[0-9]+" } +documentsEditPage: + path: /edit/{documentId} + defaults: + _controller: Themes\Rozier\Controllers\Documents\DocumentsController::editAction + requirements: { documentId : "[0-9]+" } +documentsLimitationsPage: + path: /limitations/{id} + defaults: + _controller: RZ\Roadiz\RozierBundle\Controller\Document\DocumentLimitationsController::limitationsAction + requirements: { id : "[0-9]+" } +documentsMetaPage: + path: /meta/{documentId}/{translationId} + defaults: + _controller: Themes\Rozier\Controllers\Documents\DocumentTranslationsController::editAction + translationId : null + requirements: { documentId : "[0-9]+", translationId : "[0-9]+" } +documentsPreviewPage: + path: /preview/{documentId} + defaults: + _controller: RZ\Roadiz\RozierBundle\Controller\Document\DocumentPreviewController::previewAction + requirements: { documentId : "[0-9]+" } +documentsDownloadPage: + path: /download/{documentId} + defaults: + _controller: Themes\Rozier\Controllers\Documents\DocumentsController::downloadAction + requirements: { documentId : "[0-9]+" } +documentsUsagePage: + path: /usage/{documentId} + defaults: + _controller: Themes\Rozier\Controllers\Documents\DocumentsController::usageAction + requirements: { documentId : "[0-9]+" } +documentsUploadPage: + path: /upload/{_format}/{folderId} + defaults: + _controller: Themes\Rozier\Controllers\Documents\DocumentsController::uploadAction + folderId: null + _format : html + requirements: + folderId : "[0-9]+" + _format : "html|json" +documentsRandomPage: + path: /random/{folderId} + defaults: + _controller: Themes\Rozier\Controllers\Documents\DocumentsController::randomAction + folderId: null + requirements: { folderId : "[0-9]+" } +documentsEmbedPage: + path: /embed/{folderId} + defaults: + _controller: Themes\Rozier\Controllers\Documents\DocumentsController::embedAction + folderId: null + requirements: { folderId : "[0-9]+" } +documentsDeletePage: + path: /delete/{documentId} + defaults: + _controller: Themes\Rozier\Controllers\Documents\DocumentsController::deleteAction + requirements: { documentId : "[0-9]+" } +documentsBulkDeletePage: + path: /bulk-delete + defaults: + _controller: Themes\Rozier\Controllers\Documents\DocumentsController::bulkDeleteAction +documentsBulkDownloadPage: + path: /bulk-download + defaults: + _controller: RZ\Roadiz\RozierBundle\Controller\Document\DocumentArchiveController::bulkDownloadAction +documentsUnusedPage: + path: /orphans + defaults: + _controller: RZ\Roadiz\RozierBundle\Controller\Document\DocumentUnusedController::unusedAction +documentsDuplicatesPage: + path: /duplicates + defaults: + _controller: RZ\Roadiz\RozierBundle\Controller\Document\DocumentDuplicatesController::duplicatedAction + diff --git a/lib/RoadizRozierBundle/config/routing/folders.yml b/lib/RoadizRozierBundle/config/routing/folders.yml new file mode 100644 index 00000000..fb290d36 --- /dev/null +++ b/lib/RoadizRozierBundle/config/routing/folders.yml @@ -0,0 +1,33 @@ +foldersEditPage: + path: /edit/{folderId} + defaults: + _controller: Themes\Rozier\Controllers\FoldersController::editAction + requirements: { folderId : "[0-9]+" } +foldersEditTranslationPage: + path: /edit/{folderId}/translation/{translationId} + defaults: + _controller: Themes\Rozier\Controllers\FoldersController::editTranslationAction + requirements: + folderId : "[0-9]+" + translationId : "[0-9]+" +foldersDownloadPage: + path: /download/{folderId} + defaults: + _controller: Themes\Rozier\Controllers\FoldersController::downloadAction + requirements: { folderId : "[0-9]+" } +foldersDeletePage: + path: /delete/{folderId} + defaults: + _controller: Themes\Rozier\Controllers\FoldersController::deleteAction + requirements: { folderId : "[0-9]+" } +foldersAddPage: + path: /add/{parentFolderId} + defaults: + _controller: Themes\Rozier\Controllers\FoldersController::addAction + parentFolderId: null + requirements: { parentFolderId : "[0-9]+" } +foldersExportPage: + path: /export/{folderId} + defaults: + _controller: Themes\Rozier\Controllers\FoldersController::exportAction + requirements: { folderId : "[0-9]+" } diff --git a/lib/RoadizRozierBundle/config/routing/groups.yml b/lib/RoadizRozierBundle/config/routing/groups.yml new file mode 100644 index 00000000..8ba1fe8c --- /dev/null +++ b/lib/RoadizRozierBundle/config/routing/groups.yml @@ -0,0 +1,47 @@ +groupsEditPage: + path: /edit/{id} + defaults: + _controller: Themes\Rozier\Controllers\GroupsController::editAction + requirements: { id : "[0-9]+" } +groupsEditRolesPage: + path: /edit/{id}/roles + defaults: + _controller: Themes\Rozier\Controllers\GroupsController::editRolesAction + requirements: { id : "[0-9]+" } +groupsRemoveRolesPage: + path: /edit/{id}/roles/{roleId}/remove + defaults: + _controller: Themes\Rozier\Controllers\GroupsController::removeRolesAction + requirements: { id : "[0-9]+", roleId : "[0-9]+" } +groupsAddPage: + path: /add + defaults: + _controller: Themes\Rozier\Controllers\GroupsController::addAction +groupsDeletePage: + path: /delete/{id} + defaults: + _controller: Themes\Rozier\Controllers\GroupsController::deleteAction + requirements: { id : "[0-9]+" } +groupsImportPage: + path: /import + defaults: + _controller: Themes\Rozier\Controllers\GroupsUtilsController::importJsonFileAction +groupsEditUsersPage: + path: /edit/{id}/users + defaults: + _controller: Themes\Rozier\Controllers\GroupsController::editUsersAction + requirements: { id : "[0-9]+" } +groupsRemoveUsersPage: + path: /edit/{id}/users/{userId}/remove + defaults: + _controller: Themes\Rozier\Controllers\GroupsController::removeUsersAction + requirements: { id : "[0-9]+", userId : "[0-9]+" } +groupsExportAllPage: + path: /export + defaults: + _controller: Themes\Rozier\Controllers\GroupsUtilsController::exportAllAction +groupsExportPage: + path: /export/{id} + requirements: { id : "[0-9]+" } + defaults: + _controller: Themes\Rozier\Controllers\GroupsUtilsController::exportAction diff --git a/lib/RoadizRozierBundle/config/routing/login.yml b/lib/RoadizRozierBundle/config/routing/login.yml new file mode 100644 index 00000000..d92856c1 --- /dev/null +++ b/lib/RoadizRozierBundle/config/routing/login.yml @@ -0,0 +1,23 @@ +--- +loginRequestPage: + path: /login/request + controller: RZ\Roadiz\RozierBundle\Controller\Login\LoginRequestController::indexAction +loginRequestConfirmPage: + path: /login/request/confirm + controller: RZ\Roadiz\RozierBundle\Controller\Login\LoginRequestController::confirmAction +loginResetConfirmPage: + path: /login/reset/confirm + controller: Themes\Rozier\Controllers\LoginResetController::confirmAction +loginResetPage: + path: /login/reset/{token} + controller: Themes\Rozier\Controllers\LoginResetController::resetAction + requirements: { token : "[^\\/]+" } + +# Override legacy Rozier routes for security +loginPage: + path: /login + controller: RZ\Roadiz\RozierBundle\Controller\SecurityController::login + +logoutPage: + path: /logout + controller: RZ\Roadiz\RozierBundle\Controller\SecurityController::logout diff --git a/lib/RoadizRozierBundle/config/routing/node-type-fields.yml b/lib/RoadizRozierBundle/config/routing/node-type-fields.yml new file mode 100644 index 00000000..5a786c7a --- /dev/null +++ b/lib/RoadizRozierBundle/config/routing/node-type-fields.yml @@ -0,0 +1,22 @@ +--- +nodeTypeFieldsListPage: + path: /{nodeTypeId} + defaults: + _controller: Themes\Rozier\Controllers\NodeTypeFieldsController::listAction + requirements: { nodeTypeId : "[0-9]+" } +nodeTypeFieldsEditPage: + path: /edit/{nodeTypeFieldId} + defaults: + _controller: Themes\Rozier\Controllers\NodeTypeFieldsController::editAction + requirements: { nodeTypeFieldId : "[0-9]+" } +nodeTypeFieldsAddPage: + path: /add/{nodeTypeId} + defaults: + _controller: Themes\Rozier\Controllers\NodeTypeFieldsController::addAction + requirements: { nodeTypeId : "[0-9]+" } +nodeTypeFieldsDeletePage: + path: /delete/{nodeTypeFieldId} + defaults: + _controller: Themes\Rozier\Controllers\NodeTypeFieldsController::deleteAction + requirements: { nodeTypeFieldId : "[0-9]+" } + diff --git a/lib/RoadizRozierBundle/config/routing/node-types.yml b/lib/RoadizRozierBundle/config/routing/node-types.yml new file mode 100644 index 00000000..c10f950a --- /dev/null +++ b/lib/RoadizRozierBundle/config/routing/node-types.yml @@ -0,0 +1,39 @@ +# +# Node-types admin pages +# These routes are prefixed with /rz-admin/node-types +# +nodeTypesEditPage: + path: /edit/{nodeTypeId} + defaults: + _controller: Themes\Rozier\Controllers\NodeTypes\NodeTypesController::editAction + requirements: { nodeTypeId : "[0-9]+" } +nodeTypesAddPage: + path: /add + defaults: + _controller: Themes\Rozier\Controllers\NodeTypes\NodeTypesController::addAction +nodeTypesImportPage: + path: /import + defaults: + _controller: Themes\Rozier\Controllers\NodeTypes\NodeTypesUtilsController::importJsonFileAction +nodeTypesDeletePage: + path: /delete/{nodeTypeId} + defaults: + _controller: Themes\Rozier\Controllers\NodeTypes\NodeTypesController::deleteAction + requirements: { nodeTypeId : "[0-9]+" } +nodesTypesExportPage: + path: /export/{nodeTypeId} + defaults: + _controller: Themes\Rozier\Controllers\NodeTypes\NodeTypesUtilsController::exportJsonFileAction + requirements: { nodeTypeId : "[0-9]+" } +nodesTypesExportAllPage: + path: /export/all + defaults: + _controller: Themes\Rozier\Controllers\NodeTypes\NodeTypesUtilsController::exportAllAction +nodesTypesExportDocumentationPage: + path: /export/documentation + defaults: + _controller: Themes\Rozier\Controllers\NodeTypes\NodeTypesUtilsController::exportDocumentationAction +nodesTypesExportTypeScriptPage: + path: /export/typescript + defaults: + _controller: Themes\Rozier\Controllers\NodeTypes\NodeTypesUtilsController::exportTypeScriptDeclarationAction diff --git a/lib/RoadizRozierBundle/config/routing/nodes.yml b/lib/RoadizRozierBundle/config/routing/nodes.yml new file mode 100644 index 00000000..e6662372 --- /dev/null +++ b/lib/RoadizRozierBundle/config/routing/nodes.yml @@ -0,0 +1,177 @@ +# +# Node admin pages +# These routes are prefixed with /rz-admin/nodes +# +nodesHomeDraftPage: + path: /drafts + defaults: + _controller: Themes\Rozier\Controllers\Nodes\NodesController::indexAction + filter: 'draft' +nodesHomePendingPage: + path: /pending + defaults: + _controller: Themes\Rozier\Controllers\Nodes\NodesController::indexAction + filter: 'pending' +nodesHomeArchivedPage: + path: /archived + defaults: + _controller: Themes\Rozier\Controllers\Nodes\NodesController::indexAction + filter: 'archived' +nodesHomeDeletedPage: + path: /deleted + defaults: + _controller: Themes\Rozier\Controllers\Nodes\NodesController::indexAction + filter: 'deleted' +nodesEditPage: + path: /edit/{nodeId} + defaults: + _controller: Themes\Rozier\Controllers\Nodes\NodesController::editAction + requirements: { nodeId : "[0-9]+" } +nodesTranstypePage: + path: /edit/{nodeId}/transtype + defaults: + _controller: Themes\Rozier\Controllers\Nodes\TranstypeController::transtypeAction + requirements: { nodeId : "[0-9]+" } +nodesTranslatePage: + path: /translate/{nodeId} + defaults: + _controller: RZ\Roadiz\RozierBundle\Controller\Node\TranslateController::translateAction + requirements: { nodeId : "[0-9]+" } +nodesEditSourcePage: + path: /edit/{nodeId}/source/{translationId} + defaults: + _controller: Themes\Rozier\Controllers\Nodes\NodesSourcesController::editSourceAction + requirements: { nodeId : "[0-9]+", translationId : "[0-9]+" } +nodesDeleteSourcePage: + path: /source/delete/{nodeSourceId} + defaults: + _controller : Themes\Rozier\Controllers\Nodes\NodesSourcesController::removeAction + requirements: {nodeSourceId : "[0-9]+"} + +nodesEditAttributesPage: + path: /edit/{nodeId}/source/{translationId}/attributes + defaults: + _controller: Themes\Rozier\Controllers\Nodes\NodesAttributesController::editAction + requirements: { nodeId : "[0-9]+", translationId : "[0-9]+" } + +nodesDeleteAttributesPage: + path: /edit/{nodeId}/source/{translationId}/attributes/{attributeValueId}/delete + defaults: + _controller: Themes\Rozier\Controllers\Nodes\NodesAttributesController::deleteAction + requirements: { nodeId : "[0-9]+", translationId : "[0-9]+", attributeValueId : "[0-9]+" } + +nodesResetAttributesPage: + path: /edit/{nodeId}/source/{translationId}/attributes/{attributeValueId}/reset + defaults: + _controller: Themes\Rozier\Controllers\Nodes\NodesAttributesController::resetAction + requirements: { nodeId: "[0-9]+", translationId: "[0-9]+", attributeValueId: "[0-9]+" } + +nodesEditTagsPage: + path: /edit/{nodeId}/tags + defaults: + _controller: RZ\Roadiz\RozierBundle\Controller\Node\NodesTagsController::editTagsAction + requirements: { nodeId : "[0-9]+" } +nodesRemoveStackTypePage: + path: /edit/{nodeId}/stacktype/{typeId}/remove + methods: [POST] + defaults: + _controller: Themes\Rozier\Controllers\Nodes\NodesController::removeStackTypeAction + requirements: { nodeId : "[0-9]+", typeId : "[0-9]+" } +nodesEditSEOPage: + path: /edit/{nodeId}/seo/{translationId} + defaults: + _controller: RZ\Roadiz\RozierBundle\Controller\Node\SeoController::editAliasesAction + requirements: { nodeId : "[0-9]+" } +nodesTreePage: + path: /tree/{nodeId}/{translationId} + defaults: + _controller: Themes\Rozier\Controllers\Nodes\NodesTreesController::treeAction + nodeId : null + translationId : null + requirements: + nodeId : "[0-9]+" + translationId : "[0-9]+" +nodesBulkDeletePage: + path: /bulk-delete + defaults: + _controller: Themes\Rozier\Controllers\Nodes\NodesTreesController::bulkDeleteAction +nodesBulkStatusPage: + path: /bulk-status + defaults: + _controller: Themes\Rozier\Controllers\Nodes\NodesTreesController::bulkStatusAction +nodesAddPage: + path: /add/{nodeTypeId}/{translationId} + defaults: + _controller: Themes\Rozier\Controllers\Nodes\NodesController::addAction + requirements: { nodeTypeId : "[0-9]+", translationId : "[0-9]+" } +nodesAddChildPage: + path: /add-child/{nodeId} + defaults: + _controller: Themes\Rozier\Controllers\Nodes\NodesController::addChildAction + nodeId: ~ + requirements: { nodeId : "[0-9]+" } +nodesDeletePage: + path: /delete/{nodeId} + defaults: + _controller: Themes\Rozier\Controllers\Nodes\NodesController::deleteAction + requirements: { nodeId : "[0-9]+" } +nodesUndeletePage: + path: /undelete/{nodeId} + defaults: + _controller: Themes\Rozier\Controllers\Nodes\NodesController::undeleteAction + requirements: { nodeId : "[0-9]+" } + +nodesExportAllXlsxPage: + path: /export/xlsx/{translationId} + defaults: + _controller: Themes\Rozier\Controllers\Nodes\ExportController::exportAllXlsxAction + requirements: + translationId : "[0-9]+" + +nodesExportNodeXlsxPage: + path: /export/{parentNodeId}/xlsx/{translationId} + defaults: + _controller: Themes\Rozier\Controllers\Nodes\ExportController::exportAllXlsxAction + requirements: + translationId: "[0-9]+" + parentNodeId: "[0-9]+" + +nodesDuplicatePage: + path: /duplicate/{nodeId} + defaults: + _controller: Themes\Rozier\Controllers\Nodes\NodesUtilsController::duplicateAction + requirements: { nodeId : "[0-9]+" } +nodesEmptyTrashPage: + path: /empty-trash + defaults: + _controller: Themes\Rozier\Controllers\Nodes\NodesController::emptyTrashAction +nodesGenerateAndAddNodeAction: + path: /create + defaults: + _controller: Themes\Rozier\Controllers\Nodes\NodesController::generateAndAddNodeAction +nodesPublishAllAction: + path: /publish-all/{nodeId} + defaults: + _controller: Themes\Rozier\Controllers\Nodes\NodesController::publishAllAction + requirements: { nodeId : "[0-9]+" } +nodesHistoryPage: + path: /history/{nodeId}/{page} + defaults: + _controller: Themes\Rozier\Controllers\Nodes\HistoryController::historyAction + page : "1" + requirements: + nodeId : "[0-9]+" + page : "[0-9]+" + +nodesRealmsPage: + path: /realms/{id} + defaults: + _controller: RZ\Roadiz\RozierBundle\Controller\Node\RealmNodeController::defaultAction + requirements: { id : "[0-9]+" } +nodesRealmsDeletePage: + path: /realms/{id}/delete/{realmNodeId} + defaults: + _controller: RZ\Roadiz\RozierBundle\Controller\Node\RealmNodeController::deleteAction + requirements: + id : "[0-9]+" + realmNodeId : "[0-9]+" diff --git a/lib/RoadizRozierBundle/config/routing/realms.yml b/lib/RoadizRozierBundle/config/routing/realms.yml new file mode 100644 index 00000000..c9fc219b --- /dev/null +++ b/lib/RoadizRozierBundle/config/routing/realms.yml @@ -0,0 +1,14 @@ +realmsEditPage: + path: /edit/{id} + defaults: + _controller: RZ\Roadiz\RozierBundle\Controller\Realm\RealmController::editAction + requirements: { id : "[0-9]+" } +realmsAddPage: + path: /add + defaults: + _controller: RZ\Roadiz\RozierBundle\Controller\Realm\RealmController::addAction +realmsDeletePage: + path: /delete/{id} + defaults: + _controller: RZ\Roadiz\RozierBundle\Controller\Realm\RealmController::deleteAction + requirements: { id : "[0-9]+" } diff --git a/lib/RoadizRozierBundle/config/routing/redirections.yml b/lib/RoadizRozierBundle/config/routing/redirections.yml new file mode 100644 index 00000000..436d3511 --- /dev/null +++ b/lib/RoadizRozierBundle/config/routing/redirections.yml @@ -0,0 +1,14 @@ +redirectionsEditPage: + path: /edit/{id} + defaults: + _controller: Themes\Rozier\Controllers\RedirectionsController::editAction + requirements: { id : "[0-9]+" } +redirectionsAddPage: + path: /add + defaults: + _controller: Themes\Rozier\Controllers\RedirectionsController::addAction +redirectionsDeletePage: + path: /delete/{id} + defaults: + _controller: Themes\Rozier\Controllers\RedirectionsController::deleteAction + requirements: { id : "[0-9]+" } diff --git a/lib/RoadizRozierBundle/config/routing/roles.yml b/lib/RoadizRozierBundle/config/routing/roles.yml new file mode 100644 index 00000000..ed9201fc --- /dev/null +++ b/lib/RoadizRozierBundle/config/routing/roles.yml @@ -0,0 +1,27 @@ +rolesEditPage: + path: /edit/{id} + defaults: + _controller: Themes\Rozier\Controllers\RolesController::editAction + requirements: { id : "[0-9]+" } +rolesAddPage: + path: /add + defaults: + _controller: Themes\Rozier\Controllers\RolesController::addAction +rolesDeletePage: + path: /delete/{id} + defaults: + _controller: Themes\Rozier\Controllers\RolesController::deleteAction + requirements: { id : "[0-9]+" } +rolesExportAllPage: + path: /export + defaults: + _controller: Themes\Rozier\Controllers\RolesController::exportAction +rolesExportPage: + path: /export/{id} + requirements: { id : "[0-9]+" } + defaults: + _controller: Themes\Rozier\Controllers\RolesUtilsController::exportAction +rolesImportPage: + path: /import + defaults: + _controller: Themes\Rozier\Controllers\RolesUtilsController::importJsonFileAction diff --git a/lib/RoadizRozierBundle/config/routing/setting-groups.yml b/lib/RoadizRozierBundle/config/routing/setting-groups.yml new file mode 100644 index 00000000..8a62458c --- /dev/null +++ b/lib/RoadizRozierBundle/config/routing/setting-groups.yml @@ -0,0 +1,19 @@ +settingGroupsSettingsPage: + path: /settings/{settingGroupId} + defaults: + _controller: Themes\Rozier\Controllers\SettingsController::byGroupAction + requirements: { settingGroupId : "[0-9]+" } +settingGroupsEditPage: + path: /edit/{id} + defaults: + _controller: Themes\Rozier\Controllers\SettingGroupsController::editAction + requirements: { id : "[0-9]+" } +settingGroupsAddPage: + path: /add + defaults: + _controller: Themes\Rozier\Controllers\SettingGroupsController::addAction +settingGroupsDeletePage: + path: /delete/{id} + defaults: + _controller: Themes\Rozier\Controllers\SettingGroupsController::deleteAction + requirements: { id : "[0-9]+" } diff --git a/lib/RoadizRozierBundle/config/routing/settings.yml b/lib/RoadizRozierBundle/config/routing/settings.yml new file mode 100644 index 00000000..55fe4b74 --- /dev/null +++ b/lib/RoadizRozierBundle/config/routing/settings.yml @@ -0,0 +1,27 @@ +settingsEditPage: + path: /edit/{settingId} + defaults: + _controller: Themes\Rozier\Controllers\SettingsController::editAction + requirements: { settingId : "[0-9]+" } +settingsAddPage: + path: /add + defaults: + _controller: Themes\Rozier\Controllers\SettingsController::addAction +settingsDeletePage: + path: /delete/{settingId} + defaults: + _controller: Themes\Rozier\Controllers\SettingsController::deleteAction + requirements: { settingId : "[0-9]+" } +settingsExportAllPage: + path: /export + defaults: + _controller: Themes\Rozier\Controllers\SettingsUtilsController::exportAllAction +settingsExportGroupPage: + path: /export/{settingGroupId} + defaults: + _controller: Themes\Rozier\Controllers\SettingsUtilsController::exportAllAction + requirements: { settingGroupId: "[0-9]+" } +settingsImportPage: + path: /import + defaults: + _controller: Themes\Rozier\Controllers\SettingsUtilsController::importJsonFileAction diff --git a/lib/RoadizRozierBundle/config/routing/tags.yml b/lib/RoadizRozierBundle/config/routing/tags.yml new file mode 100644 index 00000000..0807770d --- /dev/null +++ b/lib/RoadizRozierBundle/config/routing/tags.yml @@ -0,0 +1,57 @@ +tagsEditNodesPage: + path: /edit/{tagId}/nodes + defaults: + _controller: Themes\Rozier\Controllers\Tags\TagsController::editNodesAction + requirements: { tagId : "[0-9]+" } +tagsTreePage: + path: /tree/{tagId} + defaults: + _controller: Themes\Rozier\Controllers\Tags\TagsController::treeAction + requirements: { tagId : "[0-9]+" } +tagsEditPage: + path: /edit/{tagId} + defaults: + _controller: Themes\Rozier\Controllers\Tags\TagsController::editTranslatedAction + requirements: { tagId : "[0-9]+" } +tagsSettingsPage: + path: /edit/{tagId}/settings + defaults: + _controller: Themes\Rozier\Controllers\Tags\TagsController::editSettingsAction + requirements: { tagId : "[0-9]+" } +tagsEditTranslatedPage: + path: /edit/{tagId}/translation/{translationId} + defaults: + _controller: Themes\Rozier\Controllers\Tags\TagsController::editTranslatedAction + requirements: { tagId : "[0-9]+", translationId : "[0-9]+" } +tagsAddPage: + path: /add + defaults: + _controller: Themes\Rozier\Controllers\Tags\TagsController::addAction +tagsAddChildPage: + path: /add-child/{tagId} + defaults: + _controller: Themes\Rozier\Controllers\Tags\TagsController::addChildAction + requirements: { tagId : "[0-9]+" } +tagsAddMultipleChildPage: + path: /add-multiple-child/{parentTagId} + defaults: + _controller: Themes\Rozier\Controllers\Tags\TagMultiCreationController::addChildAction + requirements: { parentTagId : "[0-9]+" } +tagsDeletePage: + path: /delete/{tagId} + defaults: + _controller: Themes\Rozier\Controllers\Tags\TagsController::deleteAction + requirements: { tagId : "[0-9]+" } +tagsExportPage: + path: /export/{tagId} + defaults: + _controller: Themes\Rozier\Controllers\Tags\TagsUtilsController::exportAction + requirements: { tagId : "[0-9]+" } +tagsExportAllPage: + path: /export + defaults: + _controller: Themes\Rozier\Controllers\Tags\TagsUtilsController::exportAction +tagsBulkDeletePage: + path: /bulk-delete + defaults: + _controller: Themes\Rozier\Controllers\Tags\TagsController::bulkDeleteAction diff --git a/lib/RoadizRozierBundle/config/routing/translations.yml b/lib/RoadizRozierBundle/config/routing/translations.yml new file mode 100644 index 00000000..feeaaadd --- /dev/null +++ b/lib/RoadizRozierBundle/config/routing/translations.yml @@ -0,0 +1,19 @@ +translationsEditPage: + path: /edit/{translationId} + defaults: + _controller: Themes\Rozier\Controllers\TranslationsController::editAction + requirements: { translationId : "[0-9]+" } +translationsSetDefaultPage: + path: /set-default/{translationId} + defaults: + _controller: Themes\Rozier\Controllers\TranslationsController::setDefaultAction + requirements: { translationId : "[0-9]+" } +translationsAddPage: + path: /add + defaults: + _controller: Themes\Rozier\Controllers\TranslationsController::addAction +translationsDeletePage: + path: /delete/{translationId} + defaults: + _controller: Themes\Rozier\Controllers\TranslationsController::deleteAction + requirements: { translationId : "[0-9]+" } diff --git a/lib/RoadizRozierBundle/config/routing/users.yml b/lib/RoadizRozierBundle/config/routing/users.yml new file mode 100644 index 00000000..ad410625 --- /dev/null +++ b/lib/RoadizRozierBundle/config/routing/users.yml @@ -0,0 +1,45 @@ +usersEditPage: + path: /edit/{userId} + defaults: + _controller: Themes\Rozier\Controllers\Users\UsersController::editAction + requirements: { userId : "[0-9]+" } +usersEditDetailsPage: + path: /edit/{userId}/details + defaults: + _controller: Themes\Rozier\Controllers\Users\UsersController::editDetailsAction + requirements: { userId : "[0-9]+" } +usersEditRolesPage: + path: /edit/{userId}/roles + defaults: + _controller: Themes\Rozier\Controllers\Users\UsersRolesController::editRolesAction + requirements: { userId : "[0-9]+" } +usersRemoveRolesPage: + path: /edit/{userId}/roles/{roleId}/remove + defaults: + _controller: Themes\Rozier\Controllers\Users\UsersRolesController::removeRoleAction + requirements: { userId : "[0-9]+", roleId : "[0-9]+" } +usersEditGroupsPage: + path: /edit/{userId}/groups + defaults: + _controller: Themes\Rozier\Controllers\Users\UsersGroupsController::editGroupsAction + requirements: { userId : "[0-9]+" } +usersRemoveGroupsPage: + path: /edit/{userId}/groups/{groupId}/remove + defaults: + _controller: Themes\Rozier\Controllers\Users\UsersGroupsController::removeGroupAction + requirements: { userId : "[0-9]+", groupId : "[0-9]+" } +usersAddPage: + path: /add + defaults: + _controller: Themes\Rozier\Controllers\Users\UsersController::addAction +usersDeletePage: + path: /delete/{userId} + defaults: + _controller: Themes\Rozier\Controllers\Users\UsersController::deleteAction + requirements: { userId : "[0-9]+" } + +usersSecurityPage: + path: /edit/{userId}/security + defaults: + _controller: Themes\Rozier\Controllers\Users\UsersSecurityController::securityAction + requirements: { userId : "[0-9]+" } diff --git a/lib/RoadizRozierBundle/config/routing/webhooks.yml b/lib/RoadizRozierBundle/config/routing/webhooks.yml new file mode 100644 index 00000000..f6324b44 --- /dev/null +++ b/lib/RoadizRozierBundle/config/routing/webhooks.yml @@ -0,0 +1,22 @@ +webhooksAddPage: + path: /add + defaults: + _controller: Themes\Rozier\Controllers\WebhookController::addAction + +webhooksEditPage: + path: /edit/{id} + defaults: + _controller: Themes\Rozier\Controllers\WebhookController::editAction + requirements: { id : "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}" } + +webhooksTriggerPage: + path: /trigger/{id} + defaults: + _controller: Themes\Rozier\Controllers\WebhookController::triggerAction + requirements: { id : "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}" } + +webhooksDeletePage: + path: /delete/{id} + defaults: + _controller: Themes\Rozier\Controllers\WebhookController::deleteAction + requirements: { id : "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}" } diff --git a/lib/RoadizRozierBundle/config/services.yaml b/lib/RoadizRozierBundle/config/services.yaml new file mode 100644 index 00000000..e4b72473 --- /dev/null +++ b/lib/RoadizRozierBundle/config/services.yaml @@ -0,0 +1,96 @@ +--- +services: + # default configuration for services in *this* file + _defaults: + autowire: true # Automatically injects dependencies in your services. + autoconfigure: true # Automatically registers your services as commands, event subscribers, etc. + bind: + $backofficeMenuEntries: '%roadiz_rozier.backoffice_menu_configuration%' + $documentPlatforms: '%roadiz_core.medias.supported_platforms%' + $nodeFormTypeClass: '%roadiz_rozier.node_form.class%' + $addNodeFormTypeClass: '%roadiz_rozier.add_node_form.class%' + $googleServerId: '%roadiz_core.medias.google_server_id%' + $soundcloudClientId: '%roadiz_core.medias.soundcloud_client_id%' + + RZ\Roadiz\RozierBundle\: + resource: '../src/' + exclude: + - '../src/DependencyInjection/' + - '../src/Entity/' + - '../src/Kernel.php' + - '../src/Tests/' + - '../src/Event/' + + Themes\Rozier\: + resource: '%roadiz_rozier.theme_dir%' + autowire: true + autoconfigure: true + exclude: + - '%roadiz_rozier.theme_dir%/Controllers/' + - '%roadiz_rozier.theme_dir%/Services/' + - '%roadiz_rozier.theme_dir%/Events/' + - '%roadiz_rozier.theme_dir%/Resources/' + - '%roadiz_rozier.theme_dir%/Traits/' + - '%roadiz_rozier.theme_dir%/Widgets/' + - '%roadiz_rozier.theme_dir%/Explorer/' + # Ignore vendor folder which may contain php files + - '%roadiz_rozier.theme_dir%/node_modules/' + - '%roadiz_rozier.theme_dir%/bower_components/' + - '%roadiz_rozier.theme_dir%/static/' + + Themes\Rozier\Controllers\: + resource: '%roadiz_rozier.theme_dir%/Controllers/' + autowire: true + autoconfigure: true + tags: + - container.service_subscriber + exclude: + - '%roadiz_rozier.theme_dir%/Controllers/LoginRequestController.php' + + Themes\Rozier\Explorer\FoldersProvider: + public: true + Themes\Rozier\Explorer\SettingsProvider: + public: true + Themes\Rozier\Explorer\UsersProvider: + public: true + + # Since symfony/dependency-injection 5.1: The "Psr\Container\ContainerInterface" autowiring alias is deprecated. + # Define it explicitly in your app if you want to keep using it. + Themes\Rozier\Forms\NodeSource\NodeSourceProviderType: + autoconfigure: true + autowire: false + arguments: + - '@doctrine' + - '@service_container' + + Themes\Rozier\Widgets\TreeWidgetFactory: ~ + Themes\Rozier\RozierServiceRegistry: + public: true + + roadiz_rozier.twig_loader: + class: Twig\Loader\FilesystemLoader + calls: + - prependPath: [ '%roadiz_core.documents_lib_dir%/Resources/views' ] + - addPath: [ '%roadiz_rozier.theme_dir%/Resources/views', 'RoadizRozier' ] + tags: [ 'twig.loader' ] + + # + # Open ID + # + RZ\Roadiz\OpenId\OAuth2LinkGenerator: + arguments: + - '@?roadiz_rozier.open_id.discovery' + - '@security.csrf.token_manager' + - '%roadiz_rozier.open_id.hosted_domain%' + - '%roadiz_rozier.open_id.oauth_client_id%' + - '%roadiz_rozier.open_id.scopes%' + + RZ\Roadiz\OpenId\Authentication\Provider\SettingsRoleStrategy: + arguments: [ '@RZ\Roadiz\CoreBundle\Bag\Settings' ] + tags: [ 'roadiz_rozier.jwt_role_strategy' ] + + RZ\Roadiz\OpenId\Authentication\Provider\ChainJwtRoleStrategy: ~ + RZ\Roadiz\OpenId\Authentication\Provider\OpenIdAccountProvider: ~ + + RZ\Roadiz\RozierBundle\Controller\PingController: + arguments: ['@?profiler'] diff --git a/lib/RoadizRozierBundle/crowdin.yml b/lib/RoadizRozierBundle/crowdin.yml new file mode 100644 index 00000000..50e504da --- /dev/null +++ b/lib/RoadizRozierBundle/crowdin.yml @@ -0,0 +1,8 @@ +files: + - source: /**/*.xlf + ignore: + - /**/*.%two_letters_code%.xlf + - /**/*.%locale_with_underscore%.xlf + - /**/*.%locale%.xlf + - /**/*.sr_Cyrl.xlf + translation: /%original_path%/%file_name%.%two_letters_code%.%file_extension% diff --git a/lib/RoadizRozierBundle/deprecated.php b/lib/RoadizRozierBundle/deprecated.php new file mode 100644 index 00000000..ca504736 --- /dev/null +++ b/lib/RoadizRozierBundle/deprecated.php @@ -0,0 +1,9 @@ + $alias) { + \class_alias($className, $alias); +} diff --git a/lib/RoadizRozierBundle/phpcs.xml.dist b/lib/RoadizRozierBundle/phpcs.xml.dist new file mode 100644 index 00000000..19bff0cc --- /dev/null +++ b/lib/RoadizRozierBundle/phpcs.xml.dist @@ -0,0 +1,13 @@ + + + + + + + + + + + src/ + diff --git a/lib/RoadizRozierBundle/phpstan.neon b/lib/RoadizRozierBundle/phpstan.neon new file mode 100644 index 00000000..dfc8587a --- /dev/null +++ b/lib/RoadizRozierBundle/phpstan.neon @@ -0,0 +1,35 @@ +parameters: + level: 6 + paths: + - src + doctrine: + repositoryClass: RZ\Roadiz\CoreBundle\Repository\EntityRepository + excludePaths: + - */node_modules/* + - */bower_components/* + - */static/* + ignoreErrors: + - '#Call to an undefined method RZ\\Roadiz\\CoreBundle\\Repository#' + - '#Call to an undefined method RZ\\Roadiz\\UserBundle\\Repository#' + - '#Call to an undefined method Doctrine\\Persistence\\ObjectRepository#' + - '#Call to an undefined method Doctrine\\Persistence\\ObjectManager#' + - '#Call to an undefined method Doctrine\\ORM\\EntityRepository#' + - '#Call to an undefined method Symfony\\Component\\Config\\Definition\\Builder\\NodeDefinition::#' + - '#Access to an undefined property Symfony\\Component\\Validator\\Constraint::#' + - '#RZ\\Roadiz\\Core\\Entities\\Tag does not have a field named \$translation#' + - '#does not have a field named \$node\.home#' + - '#does not have a field named \$node\.id#' + - '#does not have a field named \$node\.parent#' + - '#does not have a field named \$translation#' + # PHPStan Doctrine does not support ResolveTargetEntityListener + - '#Property ([a-zA-Z\\\:\$]+) type mapping mismatch: property can contain ([a-zA-Z\\\&\>\<]+)Interface\>?\|null but database expects ([a-zA-Z\\\&\>\<]+)\|null#' + - '#Property ([a-zA-Z\\\:\$]+) type mapping mismatch: property can contain ([a-zA-Z\\\&\>\<]+)Interface\>? but database expects ([a-zA-Z\\\&\>\<]+)#' + - '#type mapping mismatch: database can contain array\|bool\|float\|int\|JsonSerializable\|stdClass\|string\|null but property expects array\|null#' + - '#Doctrine\\ORM\\Mapping\\GeneratedValue constructor expects#' + + reportUnmatchedIgnoredErrors: false + checkGenericClassInNonGenericObjectType: false + checkMissingIterableValueType: false +includes: + - vendor/phpstan/phpstan-doctrine/extension.neon + - vendor/phpstan/phpstan-doctrine/rules.neon diff --git a/lib/RoadizRozierBundle/src/Aliases.php b/lib/RoadizRozierBundle/src/Aliases.php new file mode 100644 index 00000000..5358a283 --- /dev/null +++ b/lib/RoadizRozierBundle/src/Aliases.php @@ -0,0 +1,21 @@ + + */ + public static function getAliases(): array + { + return [ + \RZ\Roadiz\RozierBundle\Controller\BackendController::class => \RZ\Roadiz\CMS\Controllers\BackendController::class, + \RZ\Roadiz\RozierBundle\Form\DocumentTranslationType::class => \Themes\Rozier\Forms\DocumentTranslationType::class, + \RZ\Roadiz\RozierBundle\Form\CustomFormType::class => \Themes\Rozier\Forms\CustomFormType::class, + \RZ\Roadiz\RozierBundle\ListManager\SessionListFilters::class => \Themes\Rozier\Utils\SessionListFilters::class, + ]; + } +} diff --git a/lib/RoadizRozierBundle/src/Controller/BackendController.php b/lib/RoadizRozierBundle/src/Controller/BackendController.php new file mode 100644 index 00000000..be5f760d --- /dev/null +++ b/lib/RoadizRozierBundle/src/Controller/BackendController.php @@ -0,0 +1,63 @@ + AuthenticationUtils::class, + 'urlGenerator' => UrlGeneratorInterface::class, + EmailManager::class => EmailManager::class, + 'logger' => LoggerInterface::class, + 'kernel' => KernelInterface::class, + 'settingsBag' => Settings::class, + 'nodeTypesBag' => NodeTypes::class, + 'rolesBag' => Roles::class, + 'assetPackages' => Packages::class, + 'csrfTokenManager' => CsrfTokenManagerInterface::class, + OAuth2LinkGenerator::class => OAuth2LinkGenerator::class, + RozierServiceRegistry::class => RozierServiceRegistry::class, + UsersProvider::class => UsersProvider::class, + SettingsProvider::class => SettingsProvider::class, + FoldersProvider::class => FoldersProvider::class, + ]); + } + + /** + * @inheritDoc + */ + public function createEntityListManager($entity, array $criteria = [], array $ordering = []) + { + return parent::createEntityListManager($entity, $criteria, $ordering) + ->setDisplayingNotPublishedNodes(true); + } +} diff --git a/lib/RoadizRozierBundle/src/Controller/CustomForm/CustomFormUsageController.php b/lib/RoadizRozierBundle/src/Controller/CustomForm/CustomFormUsageController.php new file mode 100644 index 00000000..7cb0e0c6 --- /dev/null +++ b/lib/RoadizRozierBundle/src/Controller/CustomForm/CustomFormUsageController.php @@ -0,0 +1,22 @@ +denyAccessUnlessGranted('ROLE_ACCESS_CUSTOMFORMS'); + $customForm = $id; + $this->assignation['customForm'] = $customForm; + $this->assignation['usages'] = $customForm->getNodes(); + + return $this->render('@RoadizRozier/custom-forms/usage.html.twig', $this->assignation); + } +} diff --git a/lib/RoadizRozierBundle/src/Controller/Document/DocumentArchiveController.php b/lib/RoadizRozierBundle/src/Controller/Document/DocumentArchiveController.php new file mode 100644 index 00000000..63c77c4d --- /dev/null +++ b/lib/RoadizRozierBundle/src/Controller/Document/DocumentArchiveController.php @@ -0,0 +1,111 @@ +managerRegistry = $managerRegistry; + $this->translator = $translator; + $this->documentArchiver = $documentArchiver; + } + + /** + * Return an deletion form for multiple docs. + * + * @param Request $request + * + * @return Response + */ + public function bulkDownloadAction(Request $request): Response + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_DOCUMENTS'); + + $documentsIds = $request->get('documents', []); + if (!is_array($documentsIds) || count($documentsIds) <= 0) { + throw new ResourceNotFoundException('No selected documents to download.'); + } + + /** @var array $documents */ + $documents = $this->managerRegistry + ->getRepository(Document::class) + ->findBy([ + 'id' => $documentsIds, + ]); + + if (count($documents) > 0) { + $this->assignation['documents'] = $documents; + $form = $this->buildBulkDownloadForm($documentsIds); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + try { + return $this->documentArchiver->archiveAndServe($documents, 'Documents archive'); + } catch (\Exception $e) { + $this->getLogger()->error($e->getMessage()); + $msg = $this->translator->trans('documents.cannot_download'); + $this->publishErrorMessage($request, $msg); + } + + return $this->redirectToRoute('documentsHomePage'); + } + + $this->assignation['form'] = $form->createView(); + $this->assignation['action'] = '?' . http_build_query(['documents' => $documentsIds]); + $this->assignation['thumbnailFormat'] = [ + 'quality' => 50, + 'fit' => '128x128', + 'sharpen' => 5, + 'inline' => false, + 'picture' => true, + 'loading' => 'lazy', + ]; + + return $this->render('@RoadizRozier/documents/bulkDownload.html.twig', $this->assignation); + } + + throw new ResourceNotFoundException(); + } + + + private function buildBulkDownloadForm(array $documentsIds): FormInterface + { + $defaults = [ + 'checksum' => md5(serialize($documentsIds)), + ]; + $builder = $this->createFormBuilder($defaults, [ + 'action' => '?' . http_build_query(['documents' => $documentsIds]), + ]) + ->add('checksum', HiddenType::class, [ + 'constraints' => [ + new NotNull(), + new NotBlank(), + ], + ]); + + return $builder->getForm(); + } +} diff --git a/lib/RoadizRozierBundle/src/Controller/Document/DocumentDuplicatesController.php b/lib/RoadizRozierBundle/src/Controller/Document/DocumentDuplicatesController.php new file mode 100644 index 00000000..4f38be64 --- /dev/null +++ b/lib/RoadizRozierBundle/src/Controller/Document/DocumentDuplicatesController.php @@ -0,0 +1,68 @@ +managerRegistry = $managerRegistry; + } + + /** + * @param Request $request + * @return Response + */ + public function duplicatedAction(Request $request): Response + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_DOCUMENTS'); + + $this->assignation['duplicates'] = true; + /** @var DocumentRepository $documentRepository */ + $documentRepository = $this->managerRegistry->getRepository(Document::class); + + $listManager = new QueryBuilderListManager( + $request, + $documentRepository->getDuplicatesQueryBuilder(), + 'd' + ); + $listManager->setItemPerPage(static::DEFAULT_ITEM_PER_PAGE); + + /* + * Stored in session + */ + $sessionListFilter = new SessionListFilters('duplicated_documents_item_per_page'); + $sessionListFilter->handleItemPerPage($request, $listManager); + + $listManager->handle(); + + $this->assignation['filters'] = $listManager->getAssignation(); + $this->assignation['documents'] = $listManager->getEntities(); + $this->assignation['thumbnailFormat'] = [ + 'quality' => 50, + 'fit' => '128x128', + 'sharpen' => 5, + 'inline' => false, + 'picture' => true, + 'loading' => 'lazy', + ]; + + return $this->render('@RoadizRozier/documents/duplicated.html.twig', $this->assignation); + } +} diff --git a/lib/RoadizRozierBundle/src/Controller/Document/DocumentLimitationsController.php b/lib/RoadizRozierBundle/src/Controller/Document/DocumentLimitationsController.php new file mode 100644 index 00000000..cb2770e8 --- /dev/null +++ b/lib/RoadizRozierBundle/src/Controller/Document/DocumentLimitationsController.php @@ -0,0 +1,62 @@ +managerRegistry = $managerRegistry; + } + + public function limitationsAction(Request $request, Document $document): Response + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_DOCUMENTS_LIMITATIONS'); + + /** @var FormInterface $form */ + $form = $this->createForm(DocumentLimitationsType::class, $document, [ + 'referer' => $request->get('referer'), + ]); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $msg = $this->getTranslator()->trans('document.%name%.updated', [ + '%name%' => (string) $document, + ]); + $this->publishConfirmMessage($request, $msg); + $this->dispatchEvent( + new DocumentUpdatedEvent($document) + ); + + $this->managerRegistry->getManager()->flush(); + return $this->redirectToRoute( + 'documentsLimitationsPage', + [ + 'id' => $document->getId() + ] + ); + } + + $this->assignation['document'] = $document; + $this->assignation['rawDocument'] = $document->getRawDocument(); + $this->assignation['form'] = $form->createView(); + + return $this->render('@RoadizRozier/documents/limitations.html.twig', $this->assignation); + } +} diff --git a/lib/RoadizRozierBundle/src/Controller/Document/DocumentPreviewController.php b/lib/RoadizRozierBundle/src/Controller/Document/DocumentPreviewController.php new file mode 100644 index 00000000..bf5d26d3 --- /dev/null +++ b/lib/RoadizRozierBundle/src/Controller/Document/DocumentPreviewController.php @@ -0,0 +1,91 @@ +documentFinder = $documentFinder; + } + + /** + * @param Request $request + * @param Document $documentId + * + * @return Response + * @throws \Twig\Error\RuntimeError + */ + public function previewAction(Request $request, Document $documentId): Response + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_DOCUMENTS'); + + $document = $documentId; + + $this->assignation['document'] = $document; + $this->assignation['thumbnailFormat'] = [ + 'width' => 750, + 'controls' => true, + 'srcset' => [ + [ + 'format' => [ + 'width' => 480, + 'quality' => 80, + ], + 'rule' => '480w', + ], + [ + 'format' => [ + 'width' => 768, + 'quality' => 80, + ], + 'rule' => '768w', + ], + [ + 'format' => [ + 'width' => 1400, + 'quality' => 80, + ], + 'rule' => '1400w', + ], + ], + 'sizes' => [ + '(min-width: 1380px) 1200px', + '(min-width: 768px) 768px', + '(min-width: 480px) 480px', + ], + ]; + + $otherVideos = $this->documentFinder->findVideosWithFilename($document->getFilename()); + $otherAudios = $this->documentFinder->findAudiosWithFilename($document->getFilename()); + $otherPictures = $this->documentFinder->findPicturesWithFilename($document->getFilename()); + + $this->assignation['otherVideos'] = $otherVideos; + $this->assignation['otherAudios'] = $otherAudios; + $this->assignation['otherPictures'] = $otherPictures; + $this->assignation['thumbnailFormat']['picture'] = true; + $this->assignation['infos'] = []; + if ($document->isProcessable() || $document->isSvg()) { + $this->assignation['infos']['width'] = $document->getImageWidth() . 'px'; + $this->assignation['infos']['height'] = $document->getImageHeight() . 'px'; + } + if ($document->getMediaDuration() > 0) { + $this->assignation['infos']['duration'] = $document->getMediaDuration() . ' sec'; + } + + return $this->render('@RoadizRozier/documents/preview.html.twig', $this->assignation); + } +} diff --git a/lib/RoadizRozierBundle/src/Controller/Document/DocumentPrivateListController.php b/lib/RoadizRozierBundle/src/Controller/Document/DocumentPrivateListController.php new file mode 100644 index 00000000..5cecd2a1 --- /dev/null +++ b/lib/RoadizRozierBundle/src/Controller/Document/DocumentPrivateListController.php @@ -0,0 +1,28 @@ + true, + 'raw' => false, + ]; + } + + public function prepareBaseAssignation(): static + { + parent::prepareBaseAssignation(); + + $this->assignation['pageTitle'] = 'private_documents'; + $this->assignation['displayPrivateDocuments'] = true; + + return $this; + } +} diff --git a/lib/RoadizRozierBundle/src/Controller/Document/DocumentPublicListController.php b/lib/RoadizRozierBundle/src/Controller/Document/DocumentPublicListController.php new file mode 100644 index 00000000..b7ff31de --- /dev/null +++ b/lib/RoadizRozierBundle/src/Controller/Document/DocumentPublicListController.php @@ -0,0 +1,318 @@ +documentPlatforms = $documentPlatforms; + } + + protected function getFolder(?int $folderId): ?Folder + { + if (null === $folderId || $folderId <= 0) { + return null; + } + return $this->em()->find(Folder::class, $folderId); + } + + protected function getPreFilters(Request $request): array + { + return [ + 'private' => false, + 'raw' => false, + ]; + } + + public function prepareBaseAssignation(): static + { + parent::prepareBaseAssignation(); + + $this->assignation['pageTitle'] = 'documents'; + $this->assignation['availablePlatforms'] = $this->documentPlatforms; + $this->assignation['displayPrivateDocuments'] = false; + + return $this; + } + + /** + * @param Request $request + * @param int|null $folderId + * @return Response + * @throws RuntimeError + */ + public function indexAction(Request $request, ?int $folderId = null): Response + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_DOCUMENTS'); + + /** @var Translation $translation */ + $translation = $this->em() + ->getRepository(Translation::class) + ->findDefault(); + + $prefilters = $this->getPreFilters($request); + + $folder = $this->getFolder($folderId); + if (null !== $folder) { + $prefilters['folders'] = [$folder]; + $this->assignation['folder'] = $folder; + } + + if ( + $request->query->has('type') && + $request->query->get('type', '') !== '' + ) { + $prefilters['mimeType'] = trim($request->query->get('type', '')); + $this->assignation['mimeType'] = trim($request->query->get('type', '')); + } + + if ( + $request->query->has('embedPlatform') && + $request->query->get('embedPlatform', '') !== '' + ) { + $prefilters['embedPlatform'] = trim($request->query->get('embedPlatform', '')); + $this->assignation['embedPlatform'] = trim($request->query->get('embedPlatform', '')); + } + + /* + * Handle bulk folder form + */ + $joinFolderForm = $this->buildLinkFoldersForm(); + $joinFolderForm->handleRequest($request); + if ($joinFolderForm->isSubmitted() && $joinFolderForm->isValid()) { + $data = $joinFolderForm->getData(); + $submitFolder = $joinFolderForm->get('submitFolder'); + $submitUnfolder = $joinFolderForm->get('submitUnfolder'); + if ($submitFolder instanceof ClickableInterface && $submitFolder->isClicked()) { + $msg = $this->joinFolder($data); + } elseif ($submitUnfolder instanceof ClickableInterface && $submitUnfolder->isClicked()) { + $msg = $this->leaveFolder($data); + } else { + $msg = $this->getTranslator()->trans('wrong.request'); + } + + $this->publishConfirmMessage($request, $msg); + + return $this->redirectToRoute( + 'documentsHomePage', + ['folderId' => $folderId] + ); + } + $this->assignation['joinFolderForm'] = $joinFolderForm->createView(); + + $listManager = $this->createEntityListManager( + Document::class, + $prefilters, + ['createdAt' => 'DESC'] + ); + $listManager->setDisplayingNotPublishedNodes(true); + $listManager->setItemPerPage(static::DEFAULT_ITEM_PER_PAGE); + + /* + * Stored in session + */ + $sessionListFilter = new SessionListFilters('documents_item_per_page'); + $sessionListFilter->handleItemPerPage($request, $listManager); + + $listManager->handle(); + + $this->assignation['filters'] = $listManager->getAssignation(); + $this->assignation['documents'] = $listManager->getEntities(); + $this->assignation['translation'] = $translation; + $this->assignation['thumbnailFormat'] = [ + 'quality' => 50, + 'fit' => '128x128', + 'sharpen' => 5, + 'inline' => false, + 'picture' => true, + 'loading' => 'lazy', + ]; + + return $this->render($this->getListingTemplate($request), $this->assignation); + } + + protected function getListingTemplate(Request $request): string + { + if ($request->query->get('list') === '1') { + return '@RoadizRozier/documents/list-table.html.twig'; + } + return '@RoadizRozier/documents/list.html.twig'; + } + + /** + * @return FormInterface + */ + private function buildLinkFoldersForm(): FormInterface + { + $builder = $this->createNamedFormBuilder('folderForm') + ->add('documentsId', HiddenType::class, [ + 'attr' => ['class' => 'document-id-bulk-folder'], + 'constraints' => [ + new NotNull(), + new NotBlank(), + ], + ]) + ->add('folderPaths', TextType::class, [ + 'label' => false, + 'attr' => [ + 'class' => 'rz-folder-autocomplete', + 'placeholder' => 'list.folders.to_link', + ], + 'constraints' => [ + new NotNull(), + new NotBlank(), + ], + ]) + ->add('submitFolder', SubmitType::class, [ + 'label' => false, + 'attr' => [ + 'class' => 'uk-button uk-button-primary', + 'title' => 'link.folders', + 'data-uk-tooltip' => "{animation:true}", + ], + ]) + ->add('submitUnfolder', SubmitType::class, [ + 'label' => false, + 'attr' => [ + 'class' => 'uk-button', + 'title' => 'unlink.folders', + 'data-uk-tooltip' => "{animation:true}", + ], + ]); + + return $builder->getForm(); + } + + /** + * @param array $data + * @return string Status message + */ + private function joinFolder(array $data): string + { + $msg = $this->getTranslator()->trans('no_documents.linked_to.folders'); + + if ( + !empty($data['documentsId']) && + !empty($data['folderPaths']) + ) { + $documentsIds = explode(',', $data['documentsId']); + + $documents = $this->em() + ->getRepository(Document::class) + ->findBy([ + 'id' => $documentsIds, + ]); + + $folderPaths = explode(',', $data['folderPaths']); + $folderPaths = array_filter($folderPaths); + + foreach ($folderPaths as $path) { + /** @var Folder $folder */ + $folder = $this->em() + ->getRepository(Folder::class) + ->findOrCreateByPath($path); + + /* + * Add each selected documents + */ + foreach ($documents as $document) { + $folder->addDocument($document); + } + } + + $this->em()->flush(); + $msg = $this->getTranslator()->trans('documents.linked_to.folders'); + + /* + * Dispatch events + */ + foreach ($documents as $document) { + $this->dispatchEvent( + new DocumentInFolderEvent($document) + ); + } + } + + return $msg; + } + + /** + * @param array $data + * @return string Status message + */ + private function leaveFolder(array $data): string + { + $msg = $this->getTranslator()->trans('no_documents.removed_from.folders'); + + if ( + !empty($data['documentsId']) && + !empty($data['folderPaths']) + ) { + $documentsIds = explode(',', $data['documentsId']); + + $documents = $this->em() + ->getRepository(Document::class) + ->findBy([ + 'id' => $documentsIds, + ]); + + $folderPaths = explode(',', $data['folderPaths']); + $folderPaths = array_filter($folderPaths); + + foreach ($folderPaths as $path) { + /** @var Folder $folder */ + $folder = $this->em() + ->getRepository(Folder::class) + ->findByPath($path); + + if (null !== $folder) { + /* + * Add each selected documents + */ + foreach ($documents as $document) { + $folder->removeDocument($document); + } + } + } + $this->em()->flush(); + $msg = $this->getTranslator()->trans('documents.removed_from.folders'); + + /* + * Dispatch events + */ + foreach ($documents as $document) { + $this->dispatchEvent( + new DocumentOutFolderEvent($document) + ); + } + } + + return $msg; + } +} diff --git a/lib/RoadizRozierBundle/src/Controller/Document/DocumentUnusedController.php b/lib/RoadizRozierBundle/src/Controller/Document/DocumentUnusedController.php new file mode 100644 index 00000000..20289d38 --- /dev/null +++ b/lib/RoadizRozierBundle/src/Controller/Document/DocumentUnusedController.php @@ -0,0 +1,70 @@ +managerRegistry = $managerRegistry; + } + + /** + * See unused documents. + * + * @param Request $request + * @return Response + */ + public function unusedAction(Request $request): Response + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_DOCUMENTS'); + + $this->assignation['orphans'] = true; + /** @var DocumentRepository $documentRepository */ + $documentRepository = $this->managerRegistry->getRepository(Document::class); + + $listManager = new QueryBuilderListManager( + $request, + $documentRepository->getAllUnusedQueryBuilder(), + 'd' + ); + $listManager->setItemPerPage(static::DEFAULT_ITEM_PER_PAGE); + + /* + * Stored in session + */ + $sessionListFilter = new SessionListFilters('unused_documents_item_per_page'); + $sessionListFilter->handleItemPerPage($request, $listManager); + + $listManager->handle(); + + $this->assignation['filters'] = $listManager->getAssignation(); + $this->assignation['documents'] = $listManager->getEntities(); + $this->assignation['thumbnailFormat'] = [ + 'quality' => 50, + 'fit' => '128x128', + 'sharpen' => 5, + 'inline' => false, + 'picture' => true, + 'loading' => 'lazy', + ]; + + return $this->render('@RoadizRozier/documents/unused.html.twig', $this->assignation); + } +} diff --git a/lib/RoadizRozierBundle/src/Controller/Login/LoginRequestController.php b/lib/RoadizRozierBundle/src/Controller/Login/LoginRequestController.php new file mode 100644 index 00000000..dee26204 --- /dev/null +++ b/lib/RoadizRozierBundle/src/Controller/Login/LoginRequestController.php @@ -0,0 +1,78 @@ +logger = $logger; + $this->urlGenerator = $urlGenerator; + $this->userViewer = $userViewer; + } + + protected function getUserViewer(): UserViewer + { + return $this->userViewer; + } + + /** + * @param Request $request + * + * @return Response + * @throws \Doctrine\ORM\ORMException + * @throws \Doctrine\ORM\OptimisticLockException + */ + public function indexAction(Request $request) + { + $form = $this->createForm(LoginRequestForm::class); + $form->handleRequest($request); + + if ($form->isSubmitted()) { + if ($form->isValid()) { + $this->sendConfirmationEmail( + $form, + $this->em(), + $this->logger, + $this->urlGenerator + ); + } + /* + * Always go to confirm even if email is not valid + * for avoiding database sniffing. + */ + return $this->redirectToRoute( + 'loginRequestConfirmPage' + ); + } + + $this->assignation['form'] = $form->createView(); + + return $this->render('@RoadizRozier/login/request.html.twig', $this->assignation); + } + + /** + * @return Response + */ + public function confirmAction() + { + return $this->render('@RoadizRozier/login/requestConfirm.html.twig', $this->assignation); + } +} diff --git a/lib/RoadizRozierBundle/src/Controller/Node/NodesTagsController.php b/lib/RoadizRozierBundle/src/Controller/Node/NodesTagsController.php new file mode 100644 index 00000000..3bf2ad31 --- /dev/null +++ b/lib/RoadizRozierBundle/src/Controller/Node/NodesTagsController.php @@ -0,0 +1,93 @@ +nodeFactory = $nodeFactory; + } + + protected function getNodeFactory(): NodeFactory + { + return $this->nodeFactory; + } + + /** + * Return tags form for requested node. + * + * @param Request $request + * @param Node $nodeId + * + * @return Response + * @throws \Twig\Error\RuntimeError + */ + public function editTagsAction(Request $request, Node $nodeId): Response + { + $this->validateNodeAccessForRole('ROLE_ACCESS_NODES', $nodeId); + /** @var NodesSourcesRepository $nodeSourceRepository */ + $nodeSourceRepository = $this->em()->getRepository(NodesSources::class); + $nodeSourceRepository + ->setDisplayingAllNodesStatuses(true) + ->setDisplayingNotPublishedNodes(true); + + /** @var NodesSources|null $source */ + $source = $nodeSourceRepository->findOneByNodeAndTranslation( + $nodeId, + $this->em()->getRepository(Translation::class)->findDefault() + ); + + if (null === $source) { + throw new ResourceNotFoundException(); + } + + $node = $source->getNode(); + $form = $this->createForm(NodesTagsType::class, $node); + $form->handleRequest($request); + if ($form->isSubmitted() && $form->isValid()) { + /* + * Dispatch event + */ + $this->dispatchEvent(new NodeTaggedEvent($node)); + $this->em()->flush(); + + $msg = $this->getTranslator()->trans('node.%node%.linked.tags', [ + '%node%' => $node->getNodeName(), + ]); + $this->publishConfirmMessage($request, $msg, $source); + + return $this->redirectToRoute( + 'nodesEditTagsPage', + ['nodeId' => $node->getId()] + ); + } + + $this->assignation['translation'] = $source->getTranslation(); + $this->assignation['node'] = $node; + $this->assignation['source'] = $source; + $this->assignation['form'] = $form->createView(); + + return $this->render('@RoadizRozier/nodes/editTags.html.twig', $this->assignation); + } +} diff --git a/lib/RoadizRozierBundle/src/Controller/Node/RealmNodeController.php b/lib/RoadizRozierBundle/src/Controller/Node/RealmNodeController.php new file mode 100644 index 00000000..71ce4c10 --- /dev/null +++ b/lib/RoadizRozierBundle/src/Controller/Node/RealmNodeController.php @@ -0,0 +1,140 @@ +managerRegistry = $managerRegistry; + $this->translator = $translator; + $this->eventDispatcher = $eventDispatcher; + } + + public function defaultAction(Request $request, Node $id): Response + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_REALM_NODES'); + + $node = $id; + $realmNode = new RealmNode(); + $realmNode->setNode($node); + $realmNode->setInheritanceType(RealmInterface::INHERITANCE_ROOT); + $nodeSource = $node->getNodeSources()->first(); + if (!$nodeSource instanceof NodesSources) { + throw new ResourceNotFoundException(); + } + + $form = $this->createForm(RealmNodeType::class, $realmNode); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $this->managerRegistry->getManager()->persist($realmNode); + + // Dispatch event before flush to apply any DB changes occurring in subscribers + $this->eventDispatcher->dispatch(new NodeJoinedRealmEvent($realmNode)); + + $this->managerRegistry->getManager()->flush(); + + $msg = $this->translator->trans( + 'node.%node%.joined.%realm%', + [ + '%node%' => $nodeSource->getTitle(), + '%realm%' => $realmNode->getRealm() ? + $realmNode->getRealm()->getName() : + $this->translator->trans('node.no_realm') + ] + ); + $this->publishConfirmMessage($request, $msg); + + return $this->redirectToRoute('nodesRealmsPage', [ + 'id' => $node->getId() + ]); + } + + $this->assignation['form'] = $form->createView(); + $this->assignation['node'] = $node; + $this->assignation['source'] = $nodeSource; + $this->assignation['nodeRealms'] = $this->managerRegistry + ->getRepository(RealmNode::class) + ->findByNode($node); + $this->assignation['translation'] = $nodeSource->getTranslation(); + + return $this->render('@RoadizRozier/nodes/realms.html.twig', $this->assignation); + } + + public function deleteAction(Request $request, int $id, int $realmNodeId): Response + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_REALM_NODES'); + /** @var Node|null $node */ + $node = $this->managerRegistry->getRepository(Node::class)->find($id); + /** @var RealmNode|null $realmNode */ + $realmNode = $this->managerRegistry->getRepository(RealmNode::class)->find($realmNodeId); + if (null === $node || null === $realmNode) { + throw new ResourceNotFoundException(); + } + $nodeSource = $node->getNodeSources()->first(); + if (!$nodeSource instanceof NodesSources) { + throw new ResourceNotFoundException(); + } + + $form = $this->createForm(FormType::class); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $this->managerRegistry->getManager()->remove($realmNode); + + // Dispatch event before flush to apply any DB changes occurring in subscribers + $this->eventDispatcher->dispatch(new NodeLeftRealmEvent($realmNode)); + + $this->managerRegistry->getManager()->flush(); + + $msg = $this->translator->trans( + 'node.%node%.left.%realm%', + [ + '%node%' => $nodeSource->getTitle(), + '%realm%' => $realmNode->getRealm() ? + $realmNode->getRealm()->getName() : + $this->translator->trans('node.no_realm') + ] + ); + $this->publishConfirmMessage($request, $msg); + + return $this->redirectToRoute('nodesRealmsPage', [ + 'id' => $node->getId() + ]); + } + + $this->assignation['form'] = $form->createView(); + $this->assignation['node'] = $node; + $this->assignation['source'] = $nodeSource; + $this->assignation['realmNode'] = $realmNode; + $this->assignation['translation'] = $nodeSource->getTranslation(); + + return $this->render('@RoadizRozier/nodes/deleteRealm.html.twig', $this->assignation); + } +} diff --git a/lib/RoadizRozierBundle/src/Controller/Node/SeoController.php b/lib/RoadizRozierBundle/src/Controller/Node/SeoController.php new file mode 100644 index 00000000..9c31bfc3 --- /dev/null +++ b/lib/RoadizRozierBundle/src/Controller/Node/SeoController.php @@ -0,0 +1,378 @@ +formFactory = $formFactory; + } + + public function editAliasesAction( + Request $request, + Node $nodeId, + ?Translation $translationId = null + ): Response { + $this->denyAccessUnlessGranted('ROLE_ACCESS_NODES'); + + if (null === $translationId) { + $translation = $this->em()->getRepository(Translation::class)->findDefault(); + } else { + $translation = $translationId; + } + + if ($translation === null) { + throw new ResourceNotFoundException(); + } + + $node = $nodeId; + /** @var NodesSources|false $source */ + $source = $nodeId->getNodeSourcesByTranslation($translation)->first(); + + if ($source === false) { + throw new ResourceNotFoundException(); + } + + $redirections = $this->em() + ->getRepository(Redirection::class) + ->findBy([ + 'redirectNodeSource' => $node->getNodeSources()->toArray() + ]); + $uas = $this->em() + ->getRepository(UrlAlias::class) + ->findAllFromNode($node->getId()); + $availableTranslations = $this->em() + ->getRepository(Translation::class) + ->findAvailableTranslationsForNode($node); + + $this->assignation['node'] = $node; + $this->assignation['source'] = $source; + $this->assignation['aliases'] = []; + $this->assignation['redirections'] = []; + $this->assignation['translation'] = $translation; + $this->assignation['available_translations'] = $availableTranslations; + + /* + * SEO Form + */ + $seoForm = $this->createForm(NodeSourceSeoType::class, $source); + $seoForm->handleRequest($request); + if ($seoForm->isSubmitted() && $seoForm->isValid()) { + $this->em()->flush(); + $msg = $this->getTranslator()->trans('node.seo.updated'); + $this->publishConfirmMessage($request, $msg, $source); + /* + * Dispatch event + */ + $this->dispatchEvent(new NodesSourcesUpdatedEvent($source)); + return $this->redirectToRoute( + 'nodesEditSEOPage', + ['nodeId' => $node->getId(), 'translationId' => $translationId] + ); + } + + if (null !== $response = $this->handleAddRedirection($source, $request)) { + return $response; + } + /* + * each url alias edit form + */ + /** @var UrlAlias $alias */ + foreach ($uas as $alias) { + if (null !== $response = $this->handleSingleUrlAlias($alias, $request)) { + return $response; + } + } + + /** @var Redirection $redirection */ + foreach ($redirections as $redirection) { + if (null !== $response = $this->handleSingleRedirection($redirection, $request)) { + return $response; + } + } + + /* + * Main ADD url alias form + */ + $alias = new UrlAlias(); + $addAliasForm = $this->formFactory->createNamed( + 'add_urlalias_' . $node->getId(), + UrlAliasType::class, + $alias, + [ + 'with_translation' => true + ] + ); + $addAliasForm->handleRequest($request); + if ($addAliasForm->isSubmitted() && $addAliasForm->isValid()) { + try { + $alias = $this->addNodeUrlAlias($alias, $node, $addAliasForm->get('translation')->getData()); + $msg = $this->getTranslator()->trans('url_alias.%alias%.created.%translation%', [ + '%alias%' => $alias->getAlias(), + '%translation%' => $alias->getNodeSource()->getTranslation()->getName(), + ]); + $this->publishConfirmMessage($request, $msg, $source); + /* + * Dispatch event + */ + $this->dispatchEvent(new UrlAliasCreatedEvent($alias)); + + return $this->redirect($this->generateUrl( + 'nodesEditSEOPage', + ['nodeId' => $node->getId(), 'translationId' => $translationId] + ) . '#manage-aliases'); + } catch (EntityAlreadyExistsException $e) { + $addAliasForm->addError(new FormError($e->getMessage())); + } catch (NoTranslationAvailableException $e) { + $addAliasForm->addError(new FormError($e->getMessage())); + } + } + + $this->assignation['form'] = $addAliasForm->createView(); + if ($source->isReachable()) { + $this->assignation['seoForm'] = $seoForm->createView(); + } + + return $this->render('@RoadizRozier/nodes/editAliases.html.twig', $this->assignation); + } + + /** + * @param UrlAlias $alias + * @param Node $node + * @param Translation $translation + * @return UrlAlias + */ + private function addNodeUrlAlias(UrlAlias $alias, Node $node, Translation $translation): UrlAlias + { + /** @var NodesSources|null $nodeSource */ + $nodeSource = $this->em() + ->getRepository(NodesSources::class) + ->setDisplayingAllNodesStatuses(true) + ->setDisplayingNotPublishedNodes(true) + ->findOneBy(['node' => $node, 'translation' => $translation]); + + if ($nodeSource !== null) { + $alias->setNodeSource($nodeSource); + $this->em()->persist($alias); + $this->em()->flush(); + + return $alias; + } else { + $msg = $this->getTranslator()->trans('url_alias.no_translation.%translation%', [ + '%translation%' => $translation->getName() + ]); + throw new NoTranslationAvailableException($msg); + } + } + + /** + * @param UrlAlias $alias + * @param Request $request + * + * @return RedirectResponse|null + */ + private function handleSingleUrlAlias(UrlAlias $alias, Request $request): ?RedirectResponse + { + $editForm = $this->formFactory->createNamed( + 'edit_urlalias_' . $alias->getId(), + UrlAliasType::class, + $alias + ); + $deleteForm = $this->formFactory->createNamed('delete_urlalias_' . $alias->getId()); + // Match edit + $editForm->handleRequest($request); + if ($editForm->isSubmitted() && $editForm->isValid()) { + try { + try { + $this->em()->flush(); + $msg = $this->getTranslator()->trans( + 'url_alias.%alias%.updated', + ['%alias%' => $alias->getAlias()] + ); + $this->publishConfirmMessage($request, $msg, $alias->getNodeSource()); + /* + * Dispatch event + */ + $this->dispatchEvent(new UrlAliasUpdatedEvent($alias)); + /** @var Translation $translation */ + $translation = $alias->getNodeSource()->getTranslation(); + + return $this->redirect($this->generateUrl( + 'nodesEditSEOPage', + [ + 'nodeId' => $alias->getNodeSource()->getNode()->getId(), + 'translationId' => $translation->getId() + ] + ) . '#manage-aliases'); + } catch (\RuntimeException $exception) { + $editForm->addError(new FormError($exception->getMessage())); + } + } catch (EntityAlreadyExistsException $e) { + $editForm->addError(new FormError($e->getMessage())); + } + } + + // Match delete + $deleteForm->handleRequest($request); + if ($deleteForm->isSubmitted() && $deleteForm->isValid()) { + $this->em()->remove($alias); + $this->em()->flush(); + $msg = $this->getTranslator()->trans('url_alias.%alias%.deleted', ['%alias%' => $alias->getAlias()]); + $this->publishConfirmMessage($request, $msg, $alias->getNodeSource()); + + /* + * Dispatch event + */ + $this->dispatchEvent(new UrlAliasDeletedEvent($alias)); + + /** @var Translation $translation */ + $translation = $alias->getNodeSource()->getTranslation(); + + return $this->redirect($this->generateUrl( + 'nodesEditSEOPage', + [ + 'nodeId' => $alias->getNodeSource()->getNode()->getId(), + 'translationId' => $translation->getId() + ] + ) . '#manage-aliases'); + } + + $this->assignation['aliases'][] = [ + 'alias' => $alias, + 'editForm' => $editForm->createView(), + 'deleteForm' => $deleteForm->createView(), + ]; + + return null; + } + + /** + * @param NodesSources $source + * @param Request $request + * @return RedirectResponse|null + */ + private function handleAddRedirection(NodesSources $source, Request $request): ?RedirectResponse + { + $redirection = new Redirection(); + $redirection->setRedirectNodeSource($source); + $redirection->setType(Response::HTTP_MOVED_PERMANENTLY); + + $addForm = $this->formFactory->createNamed( + 'add_redirection', + RedirectionType::class, + $redirection, + [ + 'placeholder' => $this->generateUrl($source), + 'only_query' => true + ] + ); + + $addForm->handleRequest($request); + if ($addForm->isSubmitted() && $addForm->isValid()) { + $this->em()->persist($redirection); + $this->em()->flush(); + + /** @var Translation $translation */ + $translation = $redirection->getRedirectNodeSource()->getTranslation(); + + return $this->redirect($this->generateUrl( + 'nodesEditSEOPage', + [ + 'nodeId' => $redirection->getRedirectNodeSource()->getNode()->getId(), + 'translationId' => $translation->getId() + ] + ) . '#manage-redirections'); + } + + if ($source->isReachable()) { + $this->assignation['addRedirection'] = $addForm->createView(); + } + + return null; + } + + /** + * @param Redirection $redirection + * @param Request $request + * @return RedirectResponse|null + */ + private function handleSingleRedirection(Redirection $redirection, Request $request): ?RedirectResponse + { + $editForm = $this->formFactory->createNamed( + 'edit_redirection_' . $redirection->getId(), + RedirectionType::class, + $redirection, + [ + 'only_query' => true + ] + ); + + /** @var Translation $translation */ + $translation = $redirection->getRedirectNodeSource()->getTranslation(); + + $deleteForm = $this->formFactory->createNamed('delete_redirection_' . $redirection->getId()); + + $editForm->handleRequest($request); + if ($editForm->isSubmitted() && $editForm->isValid()) { + $this->em()->flush(); + return $this->redirect($this->generateUrl( + 'nodesEditSEOPage', + [ + 'nodeId' => $redirection->getRedirectNodeSource()->getNode()->getId(), + 'translationId' => $translation->getId() + ] + ) . '#manage-redirections'); + } + + // Match delete + $deleteForm->handleRequest($request); + if ($deleteForm->isSubmitted() && $deleteForm->isValid()) { + $this->em()->remove($redirection); + $this->em()->flush(); + return $this->redirect($this->generateUrl( + 'nodesEditSEOPage', + [ + 'nodeId' => $redirection->getRedirectNodeSource()->getNode()->getId(), + 'translationId' => $translation->getId() + ] + ) . '#manage-redirections'); + } + $this->assignation['redirections'][] = [ + 'redirection' => $redirection, + 'editForm' => $editForm->createView(), + 'deleteForm' => $deleteForm->createView(), + ]; + + return null; + } +} diff --git a/lib/RoadizRozierBundle/src/Controller/Node/TranslateController.php b/lib/RoadizRozierBundle/src/Controller/Node/TranslateController.php new file mode 100644 index 00000000..26f78abd --- /dev/null +++ b/lib/RoadizRozierBundle/src/Controller/Node/TranslateController.php @@ -0,0 +1,87 @@ +nodeTranslator = $nodeTranslator; + } + + public function translateAction(Request $request, Node $nodeId): Response + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_NODES'); + + $node = $nodeId; + + $availableTranslations = $this->em() + ->getRepository(Translation::class) + ->findUnavailableTranslationsForNode($node); + + if (count($availableTranslations) > 0) { + $form = $this->createForm(TranslateNodeType::class, null, [ + 'node' => $node, + ]); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + /** @var Translation $destinationTranslation */ + $destinationTranslation = $form->get('translation')->getData(); + /** @var Translation $sourceTranslation */ + $sourceTranslation = $form->get('sourceTranslation')->getData(); + $translateOffspring = (bool) $form->get('translate_offspring')->getData(); + + try { + $this->nodeTranslator->translateNode($sourceTranslation, $destinationTranslation, $node, $translateOffspring); + $this->em()->flush(); + $msg = $this->getTranslator()->trans('node.%name%.translated', [ + '%name%' => $node->getNodeName(), + ]); + /** @var NodesSources|false $nodeSource */ + $nodeSource = $node->getNodeSources()->first(); + $this->publishConfirmMessage( + $request, + $msg, + $nodeSource ?: null + ); + return $this->redirectToRoute( + 'nodesEditSourcePage', + ['nodeId' => $node->getId(), 'translationId' => $destinationTranslation->getId()] + ); + } catch (EntityAlreadyExistsException $e) { + $form->addError(new FormError($e->getMessage())); + } + } + $this->assignation['form'] = $form->createView(); + } + + $this->assignation['node'] = $node; + $this->assignation['translation'] = $this->em()->getRepository(Translation::class)->findDefault(); + $this->assignation['available_translations'] = []; + + foreach ($node->getNodeSources() as $ns) { + $this->assignation['available_translations'][] = $ns->getTranslation(); + } + + return $this->render('@RoadizRozier/nodes/translate.html.twig', $this->assignation); + } +} diff --git a/lib/RoadizRozierBundle/src/Controller/PingController.php b/lib/RoadizRozierBundle/src/Controller/PingController.php new file mode 100644 index 00000000..aa027753 --- /dev/null +++ b/lib/RoadizRozierBundle/src/Controller/PingController.php @@ -0,0 +1,33 @@ +profiler = $profiler; + } + + public function indexAction(): JsonResponse + { + // $profiler won't be set if your environment doesn't have the profiler (like prod, by default) + if (null !== $this->profiler) { + // if it exists, disable the profiler for this particular controller action + $this->profiler->disable(); + } + + $this->denyAccessUnlessGranted('ROLE_BACKEND_USER'); + + return new JsonResponse(null, Response::HTTP_ACCEPTED); + } +} diff --git a/lib/RoadizRozierBundle/src/Controller/Realm/RealmController.php b/lib/RoadizRozierBundle/src/Controller/Realm/RealmController.php new file mode 100644 index 00000000..679fdbe9 --- /dev/null +++ b/lib/RoadizRozierBundle/src/Controller/Realm/RealmController.php @@ -0,0 +1,95 @@ +getName() : ''; + } +} diff --git a/lib/RoadizRozierBundle/src/Controller/SecurityController.php b/lib/RoadizRozierBundle/src/Controller/SecurityController.php new file mode 100644 index 00000000..4809b28e --- /dev/null +++ b/lib/RoadizRozierBundle/src/Controller/SecurityController.php @@ -0,0 +1,75 @@ +oAuth2LinkGenerator = $oAuth2LinkGenerator; + $this->logger = $logger; + $this->settingsBag = $settingsBag; + $this->rozierServiceRegistry = $rozierServiceRegistry; + } + + #[Route(path: '/rz-admin/login', name: 'roadiz_rozier_login')] + public function login(Request $request, AuthenticationUtils $authenticationUtils): Response + { + if ($this->getUser()) { + return $this->redirectToRoute('adminHomePage'); + } + + // get the login error if there is one + $error = $authenticationUtils->getLastAuthenticationError(); + // last username entered by the user + $lastUsername = $authenticationUtils->getLastUsername(); + $assignation = [ + 'last_username' => $lastUsername, + 'error' => $error, + 'themeServices' => $this->rozierServiceRegistry + ]; + + try { + if ($this->oAuth2LinkGenerator->isSupported($request)) { + $assignation['openid_button_label'] = $this->settingsBag->get('openid_button_label'); + $assignation['openid'] = $this->oAuth2LinkGenerator->generate( + $request, + $this->generateUrl('loginPage', [], UrlGeneratorInterface::ABSOLUTE_URL) + ); + } + } catch (DiscoveryNotAvailableException $exception) { + $this->logger->notice($exception->getMessage()); + } + + return $this->render('@RoadizRozier/security/login.html.twig', $assignation); + } + + #[Route(path: '/rz-admin/logout', name: 'roadiz_rozier_logout')] + public function logout(): void + { + throw new \LogicException('This method can be blank - it will be intercepted by the logout key on your firewall.'); + } +} diff --git a/lib/RoadizRozierBundle/src/DependencyInjection/Compiler/JwtRoleStrategyCompilerPass.php b/lib/RoadizRozierBundle/src/DependencyInjection/Compiler/JwtRoleStrategyCompilerPass.php new file mode 100644 index 00000000..78bb0483 --- /dev/null +++ b/lib/RoadizRozierBundle/src/DependencyInjection/Compiler/JwtRoleStrategyCompilerPass.php @@ -0,0 +1,34 @@ +has(ChainJwtRoleStrategy::class)) { + $definition = $container->findDefinition(ChainJwtRoleStrategy::class); + $taggedServices = $container->findTaggedServiceIds( + 'roadiz_rozier.jwt_role_strategy' + ); + $taggedServicesReferences = []; + foreach ($taggedServices as $id => $tags) { + $taggedServicesReferences = new Reference($id); + } + $definition->setArgument( + '$strategies', + [$taggedServicesReferences] + ); + } + } +} diff --git a/lib/RoadizRozierBundle/src/DependencyInjection/Compiler/RozierPathsCompilerPass.php b/lib/RoadizRozierBundle/src/DependencyInjection/Compiler/RozierPathsCompilerPass.php new file mode 100644 index 00000000..994905ee --- /dev/null +++ b/lib/RoadizRozierBundle/src/DependencyInjection/Compiler/RozierPathsCompilerPass.php @@ -0,0 +1,109 @@ +hasDefinition('translator.default')) { + $this->registerThemeTranslatorResources($container); + } + } + + private function registerThemeTranslatorResources(ContainerBuilder $container): void + { + $projectDir = $container->getParameter('kernel.project_dir'); + $themeDir = $container->getParameter('roadiz_rozier.theme_dir'); + + if (!is_string($projectDir)) { + throw new \RuntimeException('kernel.project_dir is not a valid string'); + } + if (!is_string($themeDir)) { + throw new \RuntimeException('roadiz_rozier.theme_dir is not a valid string'); + } + + /* + * add Assets package '%kernel.project_dir%/themes/Rozier/static' + */ + $name = 'Rozier'; + // Register asset packages + $container->setDefinition( + 'roadiz_rozier.assets._package.' . $name, + (new Definition()) + ->setClass(PathPackage::class) + ->setArguments([ + 'themes/' . $name . '/static', + new Reference('assets.empty_version_strategy'), + new Reference('assets.context') + ]) + ->addTag('assets.package', [ + 'package' => $name + ]) + ); + + /* + * add translations paths + */ + $translationFolder = realpath($themeDir . '/Resources/translations'); + + if (false === $translationFolder || !file_exists($translationFolder)) { + throw new \RuntimeException($themeDir . '/Resources/translations' . ' is not a valid directory'); + } + + if ($container->hasDefinition('translator.default')) { + $translator = $container->findDefinition('translator.default'); + $files = []; + $finder = Finder::create() + ->followLinks() + ->files() + ->filter(function (\SplFileInfo $file) { + return 2 <= substr_count($file->getBasename(), '.') && + preg_match('/\.\w+$/', $file->getBasename()); + }) + ->in($translationFolder) + ->sortByName() + ; + foreach ($finder as $file) { + $fileNameParts = explode('.', basename((string) $file)); + $locale = $fileNameParts[\count($fileNameParts) - 2]; + if (!isset($files[$locale])) { + $files[$locale] = []; + } + + $files[$locale][] = (string) $file; + } + /** @var array $options */ + $options = $translator->getArgument(4); + + $options = array_merge_recursive( + $options, + [ + 'resource_files' => $files, + 'scanned_directories' => $scannedDirectories = [$translationFolder], + 'cache_vary' => [ + 'scanned_directories' => array_map(static function (string $dir) use ($projectDir): string { + return str_starts_with($dir, $projectDir . '/') ? substr($dir, 1 + \strlen($projectDir)) : $dir; + }, $scannedDirectories), + ], + ] + ); + + $translator->replaceArgument(4, $options); + } + } +} diff --git a/lib/RoadizRozierBundle/src/DependencyInjection/Configuration.php b/lib/RoadizRozierBundle/src/DependencyInjection/Configuration.php new file mode 100644 index 00000000..aa35dde4 --- /dev/null +++ b/lib/RoadizRozierBundle/src/DependencyInjection/Configuration.php @@ -0,0 +1,139 @@ +getRootNode() + ->addDefaultsIfNotSet() + ->children() + ->scalarNode('node_form')->defaultValue(NodeType::class)->end() + ->scalarNode('theme_dir')->defaultValue( + 'vendor/roadiz/rozier/src' + )->info('Relative path to Rozier theme sources from project directory.')->end() + ->scalarNode('add_node_form')->defaultValue(AddNodeType::class)->end() + ->arrayNode('entries') + ->defaultValue([]) + ->info('Rozier backoffice default menu entries.') + ->prototype('array') + ->children() + ->scalarNode('name')->isRequired()->end() + ->scalarNode('route')->defaultNull()->end() + ->scalarNode('path')->defaultNull()->end() + ->scalarNode('icon')->isRequired()->end() + ->arrayNode('roles') + ->prototype('scalar') + ->defaultNull() + ->end() + ->end() // roles + ->arrayNode('subentries') + ->defaultValue([]) + ->prototype('array') + ->children() + ->scalarNode('name')->isRequired()->end() + ->scalarNode('route')->defaultNull()->end() + ->scalarNode('path')->defaultNull()->end() + ->scalarNode('icon')->isRequired()->end() + ->arrayNode('roles') + ->prototype('scalar') + ->defaultNull() + ->end() + ->end() // roles + ->end() + ->end() + ->end() // subentries + ->end() + ->end() + ->end() // entries + ->end() + ->append($this->addOpenIdNode()) + ; + return $builder; + } + + /** + * @return ArrayNodeDefinition|NodeDefinition + */ + protected function addOpenIdNode() + { + $builder = new TreeBuilder('open_id'); + $node = $builder->getRootNode(); + $node->addDefaultsIfNotSet() + ->children() + ->booleanNode('verify_user_info') + ->defaultTrue() + ->info(<<end() + ->scalarNode('discovery_url') + ->defaultValue('') + ->info(<<end() + ->scalarNode('hosted_domain') + ->defaultNull() + ->info(<<end() + ->scalarNode('oauth_client_id') + ->defaultNull() + ->info(<<end() + ->scalarNode('oauth_client_secret') + ->defaultNull() + ->info(<<end() + ->scalarNode('openid_username_claim') + ->defaultValue('email') + ->info(<<end() + ->arrayNode('scopes') + ->prototype('scalar') + ->defaultValue([]) + ->info(<<end() + ->end() + ->arrayNode('granted_roles') + ->prototype('scalar') + ->defaultValue(['ROLE_USER']) + ->info(<<end() + ->end() + ->end(); + + return $node; + } +} diff --git a/lib/RoadizRozierBundle/src/DependencyInjection/RoadizRozierExtension.php b/lib/RoadizRozierBundle/src/DependencyInjection/RoadizRozierExtension.php new file mode 100644 index 00000000..1b3ceef4 --- /dev/null +++ b/lib/RoadizRozierBundle/src/DependencyInjection/RoadizRozierExtension.php @@ -0,0 +1,98 @@ +processConfiguration($configuration, $configs); + + $container->setParameter('roadiz_rozier.backoffice_menu_configuration', $config['entries']); + $container->setParameter('roadiz_rozier.node_form.class', $config['node_form']); + $container->setParameter('roadiz_rozier.add_node_form.class', $config['add_node_form']); + $container->setParameter( + 'roadiz_rozier.theme_dir', + $container->getParameter('kernel.project_dir') . DIRECTORY_SEPARATOR . trim($config['theme_dir'], "/ \t\n\r\0\x0B") + ); + + $loader = new YamlFileLoader($container, new FileLocator(dirname(__DIR__) . '/../config')); + $loader->load('services.yaml'); + + $this->registerOpenId($config, $container); + } + + private function registerOpenId(array $config, ContainerBuilder $container): void + { + $container->setParameter('roadiz_rozier.open_id.verify_user_info', $config['open_id']['verify_user_info']); + $container->setParameter('roadiz_rozier.open_id.discovery_url', $config['open_id']['discovery_url']); + $container->setParameter('roadiz_rozier.open_id.hosted_domain', $config['open_id']['hosted_domain']); + $container->setParameter('roadiz_rozier.open_id.oauth_client_id', $config['open_id']['oauth_client_id']); + $container->setParameter('roadiz_rozier.open_id.oauth_client_secret', $config['open_id']['oauth_client_secret']); + $container->setParameter('roadiz_rozier.open_id.openid_username_claim', $config['open_id']['openid_username_claim']); + $container->setParameter('roadiz_rozier.open_id.scopes', $config['open_id']['scopes'] ?? []); + $container->setParameter('roadiz_rozier.open_id.granted_roles', $config['open_id']['granted_roles'] ?? []); + + if (!empty($config['open_id']['discovery_url'])) { + $container->setDefinition( + 'roadiz_rozier.open_id.discovery', + (new Definition()) + ->setClass(Discovery::class) + ->setPublic(true) + ->setArguments([ + $config['open_id']['discovery_url'], + new Reference(\Psr\Cache\CacheItemPoolInterface::class) + ]) + ); + + $container->setDefinition( + 'roadiz_rozier.open_id.jwt_configuration_factory', + (new Definition()) + ->setClass(\RZ\Roadiz\OpenId\OpenIdJwtConfigurationFactory::class) + ->setPublic(true) + ->setArguments([ + new Reference('roadiz_rozier.open_id.discovery', ContainerInterface::NULL_ON_INVALID_REFERENCE), + $config['open_id']['hosted_domain'], + $config['open_id']['oauth_client_id'], + $config['open_id']['verify_user_info'], + ]) + ); + + $container->setDefinition( + 'roadiz_rozier.open_id.authenticator', + (new Definition()) + ->setClass(\RZ\Roadiz\OpenId\Authentication\OpenIdAuthenticator::class) + ->setPublic(true) + ->setArguments([ + new Reference('security.http_utils'), + new Reference('roadiz_rozier.open_id.discovery', ContainerInterface::NULL_ON_INVALID_REFERENCE), + new Reference(\RZ\Roadiz\OpenId\Authentication\Provider\ChainJwtRoleStrategy::class), + new Reference('roadiz_rozier.open_id.jwt_configuration_factory'), + new Reference(\Symfony\Component\Routing\Generator\UrlGeneratorInterface::class), + 'loginPage', + 'adminHomePage', + $config['open_id']['oauth_client_id'], + $config['open_id']['oauth_client_secret'], + $config['open_id']['openid_username_claim'], + '_target_path', + $config['open_id']['granted_roles'], + ]) + ); + } + } +} diff --git a/lib/RoadizRozierBundle/src/Form/CustomFormType.php b/lib/RoadizRozierBundle/src/Form/CustomFormType.php new file mode 100644 index 00000000..7bad6883 --- /dev/null +++ b/lib/RoadizRozierBundle/src/Form/CustomFormType.php @@ -0,0 +1,121 @@ +security = $security; + } + + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder->add('displayName', TextType::class, [ + 'label' => 'customForm.displayName', + 'empty_data' => '', + ]) + ->add('description', MarkdownType::class, [ + 'label' => 'description', + 'required' => false, + ]) + ->add('email', TextType::class, [ + 'label' => 'email', + 'required' => false, + 'constraints' => [ + new Callback(function ($value, ExecutionContextInterface $context) { + $emails = array_filter( + array_map('trim', explode(',', $value ?? '')) + ); + foreach ($emails as $email) { + if (false === filter_var($email, FILTER_VALIDATE_EMAIL)) { + $context->buildViolation('{{ value }} is not a valid email address.') + ->setParameter('{{ value }}', $email) + ->setCode(Email::INVALID_FORMAT_ERROR) + ->addViolation(); + } + } + }), + ], + ]) + ; + if ($this->security->isGranted('ROLE_ACCESS_CUSTOMFORMS_RETENTION')) { + $builder->add('retentionTime', ChoiceType::class, [ + 'label' => 'customForm.retentionTime', + 'help' => 'customForm.retentionTime.help', + 'required' => false, + 'placeholder' => 'customForm.retentionTime.always', + 'choices' => [ + 'customForm.retentionTime.one_week' => 'P7D', + 'customForm.retentionTime.two_weeks' => 'P14D', + 'customForm.retentionTime.one_month' => 'P1M', + 'customForm.retentionTime.three_months' => 'P3M', + 'customForm.retentionTime.six_months' => 'P6M', + 'customForm.retentionTime.one_year' => 'P1Y', + 'customForm.retentionTime.two_years' => 'P2Y', + ] + ]); + } + $builder->add('open', CheckboxType::class, [ + 'label' => 'customForm.open', + 'required' => false, + ]) + ->add('closeDate', DateTimeType::class, [ + 'label' => 'customForm.closeDate', + 'required' => false, + 'date_widget' => 'single_text', + 'date_format' => 'yyyy-MM-dd', + 'attr' => [ + 'class' => 'rz-datetime-field', + ], + 'placeholder' => [ + 'hour' => 'hour', + 'minute' => 'minute', + ], + ]) + ->add('color', ColorType::class, [ + 'label' => 'customForm.color', + 'required' => false, + ]); + } + + public function getBlockPrefix(): string + { + return 'customform'; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'label' => false, + 'name' => '', + 'data_class' => CustomForm::class, + 'attr' => [ + 'class' => 'uk-form custom-form-form', + ], + ]); + $resolver->setAllowedTypes('name', 'string'); + } +} diff --git a/lib/RoadizRozierBundle/src/Form/DataTransformer/NodesTagsTransformer.php b/lib/RoadizRozierBundle/src/Form/DataTransformer/NodesTagsTransformer.php new file mode 100644 index 00000000..cb4fff3c --- /dev/null +++ b/lib/RoadizRozierBundle/src/Form/DataTransformer/NodesTagsTransformer.php @@ -0,0 +1,64 @@ +managerRegistry = $managerRegistry; + } + + /** + * @param iterable $value + * @return int[] + */ + public function transform(mixed $value): array + { + $ids = []; + if (\is_iterable($value)) { + foreach ($value as $nodesTag) { + $ids[] = (int) $nodesTag->getTag()->getId(); + } + } + + return $ids; + } + + /** + * @param iterable $value + * @return Collection + */ + public function reverseTransform(mixed $value): Collection + { + $nodesTags = []; + if (\is_iterable($value)) { + $i = 0; + /** @var int|string $tagId */ + foreach ($value as $tagId) { + $tag = $this->managerRegistry + ->getRepository(Tag::class) + ->find($tagId); + if ($tag instanceof Tag) { + $nodesTags[] = (new NodesTags()) + ->setTag($tag) + ->setPosition(++$i) + ; + } + } + } + + return new ArrayCollection($nodesTags); + } +} diff --git a/lib/RoadizRozierBundle/src/Form/DocumentLimitationsType.php b/lib/RoadizRozierBundle/src/Form/DocumentLimitationsType.php new file mode 100644 index 00000000..89abfcb3 --- /dev/null +++ b/lib/RoadizRozierBundle/src/Form/DocumentLimitationsType.php @@ -0,0 +1,59 @@ +add('copyrightValidSince', DateTimeType::class, [ + 'label' => 'document.copyrightValidSince', + 'required' => false, + 'date_widget' => 'single_text', + 'date_format' => 'yyyy-MM-dd', + 'attr' => [ + 'class' => 'rz-datetime-field', + ], + 'placeholder' => [ + 'hour' => 'hour', + 'minute' => 'minute', + ], + ]) + ->add('copyrightValidUntil', DateTimeType::class, [ + 'label' => 'document.copyrightValidUntil', + 'required' => false, + 'date_widget' => 'single_text', + 'date_format' => 'yyyy-MM-dd', + 'attr' => [ + 'class' => 'rz-datetime-field', + ], + 'placeholder' => [ + 'hour' => 'hour', + 'minute' => 'minute', + ], + ]) + ; + } + + /** + * @inheritDoc + */ + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => Document::class + ]); + + $resolver->setRequired('referer'); + $resolver->setAllowedTypes('referer', ['null', 'string']); + } +} diff --git a/lib/RoadizRozierBundle/src/Form/DocumentTranslationType.php b/lib/RoadizRozierBundle/src/Form/DocumentTranslationType.php new file mode 100644 index 00000000..b8af8491 --- /dev/null +++ b/lib/RoadizRozierBundle/src/Form/DocumentTranslationType.php @@ -0,0 +1,53 @@ +add('referer', HiddenType::class, [ + 'data' => $options['referer'], + 'mapped' => false, + ]) + ->add('name', TextType::class, [ + 'label' => 'name', + 'required' => false, + ]) + ->add('description', MarkdownType::class, [ + 'label' => 'description', + 'required' => false, + ]) + ->add('copyright', TextType::class, [ + 'label' => 'document.copyrightHolder', + 'required' => false, + ]) + ->add('externalUrl', TextType::class, [ + 'label' => 'document.externalUrl', + 'required' => false, + ]); + } + + /** + * @inheritDoc + */ + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => DocumentTranslation::class + ]); + + $resolver->setRequired('referer'); + $resolver->setAllowedTypes('referer', ['null', 'string']); + } +} diff --git a/lib/RoadizRozierBundle/src/Form/NodesTagsType.php b/lib/RoadizRozierBundle/src/Form/NodesTagsType.php new file mode 100644 index 00000000..b4aa2efb --- /dev/null +++ b/lib/RoadizRozierBundle/src/Form/NodesTagsType.php @@ -0,0 +1,59 @@ +managerRegistry = $managerRegistry; + } + + /** + * {@inheritdoc} + * + * @param FormBuilderInterface $builder + * @param array $options + */ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder->add('nodesTags', TagsType::class, [ + 'by_reference' => false, + ]); + $builder->get('nodesTags') + ->addViewTransformer(new NodesTagsTransformer($this->managerRegistry)); + } + + /** + * {@inheritdoc} + * + * @param OptionsResolver $resolver + */ + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefault('data_class', Node::class); + } + + /** + * {@inheritdoc} + */ + public function getBlockPrefix(): string + { + return 'node_tags'; + } +} diff --git a/lib/RoadizRozierBundle/src/Form/TranslateNodeType.php b/lib/RoadizRozierBundle/src/Form/TranslateNodeType.php new file mode 100644 index 00000000..7178cc84 --- /dev/null +++ b/lib/RoadizRozierBundle/src/Form/TranslateNodeType.php @@ -0,0 +1,91 @@ +managerRegistry = $managerRegistry; + } + + /** + * @param FormBuilderInterface $builder + * @param array $options + */ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $translations = $this->managerRegistry + ->getRepository(Translation::class) + ->findUnavailableTranslationsForNode($options['node']); + $availableTranslations = $this->managerRegistry + ->getRepository(Translation::class) + ->findAvailableTranslationsForNode($options['node']); + + $builder + ->add('sourceTranslation', ChoiceType::class, [ + 'label' => 'source_translation', + 'help' => 'source_translation.help', + 'choices' => $availableTranslations, + 'required' => true, + 'multiple' => false, + 'choice_value' => 'id', + 'choice_label' => 'name', + ]) + ->add('translation', ChoiceType::class, [ + 'label' => 'destination_translation', + 'choices' => $translations, + 'required' => true, + 'multiple' => false, + 'choice_value' => 'id', + 'choice_label' => 'name', + ]) + ->add('translate_offspring', CheckboxType::class, [ + 'label' => 'translate_offspring', + 'help' => 'translate_offspring.help', + 'required' => false, + ]); + } + + /** + * @return string + */ + public function getBlockPrefix(): string + { + return 'translate_node'; + } + + /** + * @param OptionsResolver $resolver + */ + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'label' => false, + 'attr' => [ + 'class' => 'uk-form node-translation-form', + ], + ]); + + $resolver->setRequired([ + 'node', + ]); + $resolver->setAllowedTypes('node', Node::class); + } +} diff --git a/lib/RoadizRozierBundle/src/ListManager/SessionListFilters.php b/lib/RoadizRozierBundle/src/ListManager/SessionListFilters.php new file mode 100644 index 00000000..752b1c02 --- /dev/null +++ b/lib/RoadizRozierBundle/src/ListManager/SessionListFilters.php @@ -0,0 +1,61 @@ +sessionIdentifier = $sessionIdentifier; + $this->defaultItemsParPage = $defaultItemsParPage; + } + + /** + * Handle item_per_page filter form session or from request query. + * + * @param Request $request + * @param EntityListManagerInterface $listManager + */ + public function handleItemPerPage(Request $request, EntityListManagerInterface $listManager): void + { + /* + * Check if item_per_page is available from session + */ + if ( + $request->hasSession() && + $request->getSession()->has($this->sessionIdentifier) && + $request->getSession()->get($this->sessionIdentifier) > 0 && + (!$request->query->has('item_per_page') || + $request->query->get('item_per_page') < 1) + ) { + /* + * Item count is in session + */ + $request->query->set('item_per_page', intval($request->getSession()->get($this->sessionIdentifier))); + $listManager->setItemPerPage(intval($request->getSession()->get($this->sessionIdentifier))); + } elseif ( + $request->query->has('item_per_page') && + $request->query->get('item_per_page') > 0 + ) { + /* + * Item count is in query, save it in session + */ + $request->getSession()->set($this->sessionIdentifier, intval($request->query->get('item_per_page'))); + $listManager->setItemPerPage(intval($request->query->get('item_per_page'))); + } else { + $listManager->setItemPerPage($this->defaultItemsParPage); + } + } +} diff --git a/lib/RoadizRozierBundle/src/RoadizRozierBundle.php b/lib/RoadizRozierBundle/src/RoadizRozierBundle.php new file mode 100644 index 00000000..46331ef2 --- /dev/null +++ b/lib/RoadizRozierBundle/src/RoadizRozierBundle.php @@ -0,0 +1,26 @@ +addCompilerPass(new RozierPathsCompilerPass()); + $container->addCompilerPass(new JwtRoleStrategyCompilerPass()); + } +} diff --git a/lib/RoadizRozierBundle/src/Security/RozierAuthenticator.php b/lib/RoadizRozierBundle/src/Security/RozierAuthenticator.php new file mode 100644 index 00000000..1338f9e7 --- /dev/null +++ b/lib/RoadizRozierBundle/src/Security/RozierAuthenticator.php @@ -0,0 +1,9 @@ + + {% apply spaceless %} + + {% endapply %} + diff --git a/lib/RoadizRozierBundle/templates/custom-forms/usage.html.twig b/lib/RoadizRozierBundle/templates/custom-forms/usage.html.twig new file mode 100644 index 00000000..80e4576a --- /dev/null +++ b/lib/RoadizRozierBundle/templates/custom-forms/usage.html.twig @@ -0,0 +1,54 @@ +{% extends '@RoadizRozier/layout.html.twig' %} + +{% block title %}{{ customForm.displayName|u.truncate(25, '[…]', true) }} | {{ parent() }}{% endblock %} + +{% block content %} +
+ +
+

{{ customForm.displayName|u.truncate(25, '[…]', true) }}

+ {% include '@RoadizRozier/custom-forms/navBar.html.twig' with { + type: customForm, + current:'usage' + } %} +
+ {# usages #} + + +
+{% endblock %} diff --git a/lib/RoadizRozierBundle/templates/documents/duplicated.html.twig b/lib/RoadizRozierBundle/templates/documents/duplicated.html.twig new file mode 100644 index 00000000..f20ef1c0 --- /dev/null +++ b/lib/RoadizRozierBundle/templates/documents/duplicated.html.twig @@ -0,0 +1,9 @@ +{% extends "@RoadizRozier/documents/list-table.html.twig" %} + +{% block title %}{% trans %}duplicated_documents{% endtrans %} | {{ parent() }}{% endblock %} + +{% block content_title %} +

{% trans %}duplicated_documents{% endtrans %}

+{% endblock %} + +{% block header_action_menu %}{% endblock %} diff --git a/lib/RoadizRozierBundle/templates/documents/limitations.html.twig b/lib/RoadizRozierBundle/templates/documents/limitations.html.twig new file mode 100644 index 00000000..3e451c19 --- /dev/null +++ b/lib/RoadizRozierBundle/templates/documents/limitations.html.twig @@ -0,0 +1,30 @@ +{% extends '@RoadizRozier/layout.html.twig' %} + +{% block title %}{{ "edit.document.%name%"|trans({'%name%': document|u.truncate(25, '[…]', true)}) }} | {{ parent() }}{% endblock %} + +{% block content %} +
+
+

{{ "edit.document.%name%"|trans({'%name%': document|u.truncate(25, '[…]', true)}) }}

+ {% include '@RoadizRozier/documents/navBar.html.twig' with {'document':document, current:'limitations'} %} + {% include '@RoadizRozier/documents/backLink.html.twig' %} +
+ +
+ {% form_theme form '@RoadizRozier/forms.html.twig' %} + {{ form_start(form, { attr: {id: "edit-document-form"}}) }} + {{ form_widget(form) }} +
+ {% apply spaceless %} + + {% endapply %} +
+ {{ form_end(form) }} +
+ + {% include '@RoadizRozier/documents/actionsMenu.html.twig' %} +
+{% endblock %} diff --git a/lib/RoadizRozierBundle/templates/documents/list-table.html.twig b/lib/RoadizRozierBundle/templates/documents/list-table.html.twig new file mode 100644 index 00000000..f7e53168 --- /dev/null +++ b/lib/RoadizRozierBundle/templates/documents/list-table.html.twig @@ -0,0 +1,139 @@ +{% extends "@RoadizRozier/documents/list.html.twig" %} + +{% macro single_document(document) %} + + +
+ {% if not document.private and (document.isImage or document.isSvg or document.hasThumbnails) %} + {{ document|display({ + picture: true, + fit: '64x64', + quality: 70 + }) }} + {% endif %} + {% if not (document.isImage or document.isSvg) and document.shortType != 'unknown' %} +
+ {% endif %} +
+ + + {{ document|centralTruncate(30, -4) }} + + + {{ document.createdAt|format_datetime('short', 'short') }} + + {{ document.mimeType }} + + {% if document.filesize > 0 %} {{ document.filesize|formatBytes }}{% endif %} + {% if document.rawDocument %} + {% if document.rawDocument.filesize > 0 %} +
{{ document.rawDocument.filesize|formatBytes }} + {% endif %} + {% endif %} + + {% if document.imageWidth > 0 %}{{ document.imageWidth }}px{% endif %} + {% if document.imageHeight > 0 %}{{ document.imageHeight }}px{% endif %} + + {% if document.embedPlatform %} + {% set iconName = (document|embedFinder).shortType %} + {% if document.embedPlatform == 'unsplash' or document.embedPlatform == 'splashbase' %} + {% set iconName = 'documents' %} + {% endif %} + + {% endif %} + + +
+ +
+ {% apply spaceless %} + + + {% endapply %} + + +{% endmacro %} + +{% block content_body %} +
+ {% block pre_content_body %}{% endblock %} +
+ + + + + + + + + + + + + + + + {% for document in documents %} + {{- _self.single_document(document, thumbnailFormat) -}} + {% else %} + + {% endfor %} + +
+ {% trans %}document.filename{% endtrans %} + {% if not no_sorting %} + {% include '@RoadizRozier/includes/column_ordering.html.twig' with { + 'field': 'filename', + 'filters': filters, + } only %} + {% endif %} + + {% trans %}document.createdAt{% endtrans %} + {% if not no_sorting %} + {% include '@RoadizRozier/includes/column_ordering.html.twig' with { + 'field': 'createdAt', + 'filters': filters, + } only %} + {% endif %} + + {% trans %}document.mimeType{% endtrans %} + {% if not no_sorting %} + {% include '@RoadizRozier/includes/column_ordering.html.twig' with { + 'field': 'mimeType', + 'filters': filters, + } only %} + {% endif %} + + {% trans %}document.filesize{% endtrans %} + {% if not no_sorting %} + {% include '@RoadizRozier/includes/column_ordering.html.twig' with { + 'field': 'filesize', + 'filters': filters, + } only %} + {% endif %} + + {% trans %}document.width{% endtrans %} + {% if not no_sorting %} + {% include '@RoadizRozier/includes/column_ordering.html.twig' with { + 'field': 'imageWidth', + 'filters': filters, + } only %} + {% endif %} + + {% trans %}document.height{% endtrans %} + {% if not no_sorting %} + {% include '@RoadizRozier/includes/column_ordering.html.twig' with { + 'field': 'imageHeight', + 'filters': filters, + } only %} + {% endif %} + {% trans %}actions{% endtrans %}
{% trans %}no_document{% endtrans %}
+
+
+{% endblock %} diff --git a/lib/RoadizRozierBundle/templates/documents/list.html.twig b/lib/RoadizRozierBundle/templates/documents/list.html.twig new file mode 100644 index 00000000..cf9cd6a0 --- /dev/null +++ b/lib/RoadizRozierBundle/templates/documents/list.html.twig @@ -0,0 +1,61 @@ +{% extends '@RoadizRozier/layout.html.twig' %} + +{%- block title -%} + {%- if folder -%} + {%- set folderName = folder.translatedFolders.first.name|default(folder.folderName) -%} + {{- folderName|u.truncate(25, '[…]', true) }} + {%- elseif orphans -%} + {%- trans %}unused_documents{% endtrans %} + {%- else -%} + {%- trans %}documents{% endtrans %} + {% endif %} + | {{ parent() }} +{%- endblock -%} + +{% block content %} +
+
+ {% block content_title %} + {% if folder %} + {% set folderName = folder.translatedFolders.first.name|default(folder.folderName) %} +

{{ folderName|u.truncate(25, '[…]', true) }}{% if displayPrivateDocuments %} {% endif %}

+ {% include '@RoadizRozier/folders/navBar.html.twig' with {'folder':folder, current: displayPrivateDocuments ? 'private_list' : 'list'} %} + {% else %} +

{{ pageTitle|trans }}{% if displayPrivateDocuments %} {% endif %}

+ {% endif %} + {% endblock %} + {% include '@RoadizRozier/widgets/countFiltersBar.html.twig' %} + {% include '@RoadizRozier/documents/backLink.html.twig' %} + + {% if not displayPrivateDocuments %} + + {% endif %} +
+ + {% include '@RoadizRozier/documents/filtersBar.html.twig' %} + + {% block content_body %} +
+ {% block pre_content_body %}{% endblock %} + + {% apply spaceless %} +
+ {% for document in documents %} + {% include '@RoadizRozier/documents/singleDocumentThumbnail.html.twig' with { + document: document, + thumbnailFormat: thumbnailFormat, + controls: true + } only %} + {% endfor %} +
+ {% endapply %} +
+ {% endblock %} +
+{% endblock %} diff --git a/lib/RoadizRozierBundle/templates/documents/navBar.html.twig b/lib/RoadizRozierBundle/templates/documents/navBar.html.twig new file mode 100644 index 00000000..8f29a14c --- /dev/null +++ b/lib/RoadizRozierBundle/templates/documents/navBar.html.twig @@ -0,0 +1,32 @@ +{% set editRouteParams = { documentId: document.id } %} + +{% if app.request and app.request.get('referer') %} + {% set editRouteParams = editRouteParams|merge({ referer: app.request.get('referer') }) %} +{% endif %} + + diff --git a/lib/RoadizRozierBundle/templates/documents/preview.html.twig b/lib/RoadizRozierBundle/templates/documents/preview.html.twig new file mode 100644 index 00000000..99debc57 --- /dev/null +++ b/lib/RoadizRozierBundle/templates/documents/preview.html.twig @@ -0,0 +1,109 @@ +{% extends '@RoadizRozier/layout.html.twig' %} + +{% block title %}{{ "edit.document.%name%"|trans({'%name%': document|u.truncate(25, '[…]', true)}) }} | {{ parent() }}{% endblock %} + +{% block content %} +
+ +
+

{{ "edit.document.%name%"|trans({'%name%': document})|u.truncate(25, '[…]', true) }}

+ {% include '@RoadizRozier/documents/navBar.html.twig' with {'document':document, current:'preview'} %} + {% include '@RoadizRozier/documents/backLink.html.twig' %} +
+ +
+
+ {% if not document|exists %} +

{% trans %}current.document.file.does.not.exist{% endtrans %}

+ {% elseif not document.filename %} +

{% trans %}document.is_only_external{% endtrans %}

+ {% endif %} + {% if not document.private %} + {% if document.image or document.svg or document.video or document.audio or document.pdf %} + {% if document.pdf %} + {% set thumbnailFormat = thumbnailFormat|merge({'height': 700, 'embed': true}) %} + {% endif %} +
{{ document|display(thumbnailFormat) }}
+ {% if document.mimeType == 'image/gif' %} +
+
{{ document|display({noProcess:true}) }}
+ {% endif %} + {% endif %} + + {% if document.isEmbed %} +
+

{% trans %}embed.preview{% endtrans %}

+
{{ document|display(thumbnailFormat|merge({ + embed: true, + autoplay: false, + controls: true, + fullscreen: true + })) }}
+ {% endif %} + {% else %} +

{% trans %}current.document.is.private.you.cannot.preview.it{% endtrans %}

+ {% endif %} +
+
+
+
+ + + + + {% if document.isEmbed and document|embedFinder %} + + {% endif %} + + {% for key, info in infos %} + + {% endfor %} +
{% trans %}document.relative_url{% endtrans %}{{ document|url(thumbnailFormat) }}
{% trans %}document.absolute_url{% endtrans %}{{ document|url(thumbnailFormat|merge({absolute:true})) }}
{% trans %}document.unprocessed_url{% endtrans %}{{ document|url({noProcess:true}) }}
{% trans %}document.source_url{% endtrans %}{{ (document|embedFinder).source }}
{% trans %}document.filesize{% endtrans %}{{ document.filesize|formatBytes }}
{{ ('document.' ~ key)|trans }}{{ info }}
+ {% if otherVideos|length %} +
+

{% trans %}document.video_sources{% endtrans %}

+ + {% for otherVideo in otherVideos %} + + + + + + {% endfor %} +
{{ otherVideo.mimeType }}{{ otherVideo.relativePath }}
+ {% endif %} + {% if otherAudios|length %} +
+

{% trans %}document.audio_sources{% endtrans %}

+ + {% for otherAudio in otherAudios %} + + + + + + {% endfor %} +
{{ otherAudio.mimeType }}{{ otherAudio.relativePath }}
+ {% endif %} + {% if otherPictures|length %} +
+

{% trans %}document.picture_sources{% endtrans %}

+ + {% for otherPicture in otherPictures %} + + + + + + {% endfor %} +
{{ otherPicture.mimeType }}{{ otherPicture.relativePath }}
+ {% endif %} +
+
+ + {% include '@RoadizRozier/documents/actionsMenu.html.twig' %} +
+{% endblock %} diff --git a/lib/RoadizRozierBundle/templates/documents/unused.html.twig b/lib/RoadizRozierBundle/templates/documents/unused.html.twig new file mode 100644 index 00000000..7cb020a6 --- /dev/null +++ b/lib/RoadizRozierBundle/templates/documents/unused.html.twig @@ -0,0 +1,17 @@ +{% extends "@RoadizRozier/documents/list-table.html.twig" %} + +{% block title %}{% trans %}unused_documents{% endtrans %} | {{ parent() }}{% endblock %} + +{% block content_title %} +

{% trans %}unused_documents{% endtrans %}

+{% endblock %} + +{% block header_action_menu %}{% endblock %} + +{% block pre_content_body %} +
+
+ {{- 'unused_documents.help'|trans|markdown -}} +
+
+{% endblock %} diff --git a/lib/RoadizRozierBundle/templates/folders/navBar.html.twig b/lib/RoadizRozierBundle/templates/folders/navBar.html.twig new file mode 100644 index 00000000..06e98663 --- /dev/null +++ b/lib/RoadizRozierBundle/templates/folders/navBar.html.twig @@ -0,0 +1,16 @@ + diff --git a/lib/RoadizRozierBundle/templates/nodes/actionsMenu.html.twig b/lib/RoadizRozierBundle/templates/nodes/actionsMenu.html.twig new file mode 100644 index 00000000..cf8ee59d --- /dev/null +++ b/lib/RoadizRozierBundle/templates/nodes/actionsMenu.html.twig @@ -0,0 +1,243 @@ +{# Override vendor/roadiz/rozier/src/Resources/views/nodes/actionsMenu.html.twig #} +{% apply spaceless %} + +{% endapply %} diff --git a/lib/RoadizRozierBundle/templates/nodes/deleteRealm.html.twig b/lib/RoadizRozierBundle/templates/nodes/deleteRealm.html.twig new file mode 100644 index 00000000..edc6e89c --- /dev/null +++ b/lib/RoadizRozierBundle/templates/nodes/deleteRealm.html.twig @@ -0,0 +1,27 @@ +{% extends '@RoadizRozier/layout.html.twig' %} + +{% block title %}{% trans %}leave.realm{% endtrans %} | {{ parent() }}{% endblock %} + +{% block content %} +
+
+

{% trans %}leave.realm{% endtrans %}

+

{{ source.title }}{{ realmNode.realm ? ' – ' ~ realmNode.realm.name : '' }}

+ {% include '@RoadizRozier/nodes/navBack.html.twig' %} +
+
+ {% form_theme form '@RoadizRozier/forms.html.twig' %} + {{ form_start(form, { + 'attr': { + 'class': 'uk-form uk-form-stacked' + } + }) }}{{ form_widget(form) }} +
+ {% trans %}are_you_sure.leave.realm{% endtrans %} + {% trans %}cancel{% endtrans %} + +
+ {{ form_end(form) }} +
+
+{% endblock %} diff --git a/lib/RoadizRozierBundle/templates/nodes/editAliases.html.twig b/lib/RoadizRozierBundle/templates/nodes/editAliases.html.twig new file mode 100644 index 00000000..1c73d51c --- /dev/null +++ b/lib/RoadizRozierBundle/templates/nodes/editAliases.html.twig @@ -0,0 +1,118 @@ +{% set currentTitle = source.title|default(node.nodeName) %} + +{% extends '@RoadizRozier/layout.html.twig' %} + +{% block title %}{{ "edit.node.%name%.seo"|trans({'%name%': currentTitle})|u.truncate(25, '[…]', true) }} | {{ parent() }}{% endblock %} + +{% block content %} +
+
+ {% include '@RoadizRozier/nodes/breadcrumb.html.twig' with { + "node": node, + "source": source, + } only %} +

+ {{ "edit.node.%name%.seo"|trans({'%name%': currentTitle})|u.truncate(25, '[…]', true) }} + {% include '@RoadizRozier/nodes/nodeTypeCircle.html.twig' %} +

+ {% include '@RoadizRozier/nodes/navBack.html.twig' %} + {% include '@RoadizRozier/nodes/navBar.html.twig' with {"current": 'seo'} %} + {% include '@RoadizRozier/nodes/translationSEOBar.html.twig' with {"current": translation.getId} %} +
+ +
+ {% if seoForm %} +

{% trans %}edit.node.seo{% endtrans %}

+ {% form_theme seoForm '@RoadizRozier/forms.html.twig' %} + + {{ form_widget(seoForm) }} +
+ {% apply spaceless %} + + {% endapply %} +
+ +
+ {% endif %} + +

{% trans %}urlAlias{% endtrans %}

+

{% trans %}nodeSource.urlAlias.help{% endtrans %}

+ +
+ + + + + + + + + + {% if form %} + + + + {% endif %} + {% for urlAlias in aliases %} + {% include '@RoadizRozier/url-aliases/editRow.html.twig' with { + 'urlAlias': urlAlias + } %} + {% endfor %} + +
{% trans %}urlAlias{% endtrans %}{% trans %}translation{% endtrans %}{% trans %}actions{% endtrans %}
+ {% form_theme form '@RoadizRozier/horizontalForms.html.twig' %} + {{ form_start(form) }} + {{ form_widget(form) }} + + {{ form_end(form) }} +
+
+
+ +

{% trans %}manage.redirections{% endtrans %}

+

{% trans %}nodeSource.redirections.help{% endtrans %}

+ +
+ + + + + + + + + + + {% if addRedirection %} + + + + {% endif %} + {% for redirection in redirections %} + {% include '@RoadizRozier/redirections/editRow.html.twig' with { + 'redirection': redirection + } %} + {% else %} + + {% endfor %} + +
{% trans %}redirection.query{% endtrans %}{% trans %}redirection.redirect_uri{% endtrans %}{% trans %}translation{% endtrans %}{% trans %}actions{% endtrans %}
+ {% form_theme addRedirection '@RoadizRozier/horizontalForms.html.twig' %} + {{ form_start(addRedirection) }} + {{ form_widget(addRedirection) }} + + {{ form_end(addRedirection) }} +
{% trans %}no_redirection_found{% endtrans %}
+
+
+ + {% include '@RoadizRozier/nodes/actionsMenu.html.twig' %} +
+{% endblock %} diff --git a/lib/RoadizRozierBundle/templates/nodes/navBar.html.twig b/lib/RoadizRozierBundle/templates/nodes/navBar.html.twig new file mode 100644 index 00000000..1826d21d --- /dev/null +++ b/lib/RoadizRozierBundle/templates/nodes/navBar.html.twig @@ -0,0 +1,86 @@ + diff --git a/lib/RoadizRozierBundle/templates/nodes/realms.html.twig b/lib/RoadizRozierBundle/templates/nodes/realms.html.twig new file mode 100644 index 00000000..0aa262c6 --- /dev/null +++ b/lib/RoadizRozierBundle/templates/nodes/realms.html.twig @@ -0,0 +1,104 @@ + +{% set currentTitle = source.title|default(node.nodeName) %} + +{% extends '@RoadizRozier/layout.html.twig' %} + +{% block title %}{{ "edit.node.%name%.realms"|trans({'%name%': currentTitle})|u.truncate(25, '[…]', true) }} | {{ parent() }}{% endblock %} + +{% block content %} +
+
+ {% include '@RoadizRozier/nodes/breadcrumb.html.twig' with { + "node": node, + "source": source, + } only %} +

+ {{ "edit.node.%name%.realms"|trans({'%name%': currentTitle})|u.truncate(25, '[…]', true) }} + {% include '@RoadizRozier/nodes/nodeTypeCircle.html.twig' %} +

+ {% include '@RoadizRozier/nodes/navBack.html.twig' %} + {% include '@RoadizRozier/nodes/navBar.html.twig' with {"current": 'realm'} %} +
+ +
+
+ + + + + + + + + + + + {% set accessRealms = is_granted('ROLE_ACCESS_REALMS') %} + {% for nodeRealm in nodeRealms %} + + + + + + + + {% endfor %} + +
{% trans %}name{% endtrans %}{% trans %}realm.type{% endtrans %}{% trans %}realm.behaviour{% endtrans %}{% trans %}realm_node.inheritanceType{% endtrans %}{% trans %}actions{% endtrans %}
+ {% if nodeRealm.realm %} + {% if accessRealms %} + + {{ nodeRealm.realm.name }} + + {% else %} + {{ nodeRealm.realm.name }} + {% endif %} + {% else %} + {% trans %}node.no_realm{% endtrans %} + {% endif %} + {{ nodeRealm.realm.type ? ('realm.' ~ nodeRealm.realm.type)|trans : '' }}{{ nodeRealm.realm.behaviour ? ('realm.behaviour_' ~ nodeRealm.realm.behaviour)|trans : '' }}{{ ('realm_node.' ~ nodeRealm.inheritanceType)|trans }} + {% apply spaceless %} + {% if nodeRealm.realm and accessRealms %} + + + + {% endif %} + + + + + {% endapply %} +
+ +
+ + {% if form %} +
+

{% trans %}join.a.realm{% endtrans %}

+ {% form_theme form '@RoadizRozier/forms.html.twig' %} + {{ form_start(form) }} + {{ form_widget(form) }} + + {{ form_end(form) }} +
+ {% endif %} + +
+
+ + {% include '@RoadizRozier/nodes/actionsMenu.html.twig' %} +
+{% endblock %} diff --git a/lib/RoadizRozierBundle/templates/realms/actionsMenu.html.twig b/lib/RoadizRozierBundle/templates/realms/actionsMenu.html.twig new file mode 100644 index 00000000..5416a29d --- /dev/null +++ b/lib/RoadizRozierBundle/templates/realms/actionsMenu.html.twig @@ -0,0 +1,11 @@ +{% apply spaceless %} + +{% endapply %} diff --git a/lib/RoadizRozierBundle/templates/realms/add.html.twig b/lib/RoadizRozierBundle/templates/realms/add.html.twig new file mode 100644 index 00000000..be7522e4 --- /dev/null +++ b/lib/RoadizRozierBundle/templates/realms/add.html.twig @@ -0,0 +1,39 @@ +{% extends "@RoadizRozier/admin/base.html.twig" %} + +{% block title %}{% trans %}add.a.realm{% endtrans %} | {{ parent() }}{% endblock %} + +{%- block content_title -%}{% trans %}add.a.realm{% endtrans %}{%- endblock -%} + +{%- block content_header_nav -%} + +{%- endblock -%} + +{%- block content_body -%} +
+ {%- block content_body_before -%}{%- endblock -%} + {% form_theme form '@RoadizRozier/forms.html.twig' %} + {{ form_start(form, { + 'attr': { + 'id': 'edit-realm-form', + 'class': 'uk-form uk-form-stacked' + } + }) }}{{ form_widget(form) }} +
+ {% apply spaceless %} + + {% endapply %} +
+ {{ form_end(form) }} + {%- block content_body_after -%}{%- endblock -%} +
+ {% include '@RoadizRozier/realms/actionsMenu.html.twig' %} +{%- endblock -%} diff --git a/lib/RoadizRozierBundle/templates/realms/delete.html.twig b/lib/RoadizRozierBundle/templates/realms/delete.html.twig new file mode 100644 index 00000000..fccce649 --- /dev/null +++ b/lib/RoadizRozierBundle/templates/realms/delete.html.twig @@ -0,0 +1,33 @@ +{% extends "@RoadizRozier/admin/base.html.twig" %} + +{% block title %}{{- "delete.realm.%name%"|trans({'%name%': item.name}) -}} | {{ parent() }}{% endblock %} +{%- block content_title -%}{{- "delete.realm.%name%"|trans({'%name%': item.name}) -}}{%- endblock -%} + +{%- block content_header_nav -%} + +{%- endblock -%} + +{%- block content_body -%} +
+ {% form_theme form '@RoadizRozier/forms.html.twig' %} + {{ form_start(form) }} + {{ form_widget(form) }} +
+ {% trans %}are_you_sure.delete.realm{% endtrans %} + {% apply spaceless %} + {% trans %}cancel{% endtrans %} + + {% endapply %} +
+ {{ form_end(form) }} +
+{%- endblock -%} diff --git a/lib/RoadizRozierBundle/templates/realms/edit.html.twig b/lib/RoadizRozierBundle/templates/realms/edit.html.twig new file mode 100644 index 00000000..8a78d20d --- /dev/null +++ b/lib/RoadizRozierBundle/templates/realms/edit.html.twig @@ -0,0 +1,19 @@ +{% extends "@RoadizRozier/realms/add.html.twig" %} + +{% block title %}{{- "edit.realm.%name%"|trans({'%name%': item.name}) -}} | {{ parent() }}{% endblock %} +{%- block content_title -%}{{- "edit.realm.%name%"|trans({'%name%': item.name}) -}}{%- endblock -%} + +{%- block content_body_after -%} + +{%- endblock -%} diff --git a/lib/RoadizRozierBundle/templates/realms/list.html.twig b/lib/RoadizRozierBundle/templates/realms/list.html.twig new file mode 100644 index 00000000..dbfbcb93 --- /dev/null +++ b/lib/RoadizRozierBundle/templates/realms/list.html.twig @@ -0,0 +1,80 @@ +{% extends "@RoadizRozier/admin/base.html.twig" %} + +{% block title %}{% trans %}realms{% endtrans %} | {{ parent() }}{% endblock %} +{%- block content_title -%}{% trans %}realms{% endtrans %}{%- endblock -%} + +{%- block content_header_actions -%} + + {% trans %}add.a.realm{% endtrans %} + +{%- endblock -%} + +{%- block content_filters -%} + {% include '@RoadizRozier/widgets/filtersBar.html.twig' %} +{%- endblock -%} + +{%- block content_body -%} +
+
+ + + + + + + + + + + + {% for item in items %} + + + + + + + + {% endfor %} + +
+ {% trans %}name{% endtrans %} + {% include '@RoadizRozier/includes/column_ordering.html.twig' with { + 'field': 'name', + 'filters': filters, + } only %} + + {% trans %}realm.type{% endtrans %} + {% include '@RoadizRozier/includes/column_ordering.html.twig' with { + 'field': 'type', + 'filters': filters, + } only %} + + {% trans %}realm.behaviour{% endtrans %} + {% include '@RoadizRozier/includes/column_ordering.html.twig' with { + 'field': 'behaviour', + 'filters': filters, + } only %} + + {% trans %}realm.serializationGroup{% endtrans %} + {% trans %}actions{% endtrans %}
+ {{ item.name }} + {{ ('realm.' ~ item.type)|trans }}{{ ('realm.behaviour_' ~ item.behaviour)|trans }}{{ item.serializationGroup }} + {% apply spaceless %} + + + + + + + {% endapply %} +
+
+
+{%- endblock -%} diff --git a/lib/RoadizRozierBundle/templates/security/login.html.twig b/lib/RoadizRozierBundle/templates/security/login.html.twig new file mode 100644 index 00000000..b92e6c69 --- /dev/null +++ b/lib/RoadizRozierBundle/templates/security/login.html.twig @@ -0,0 +1,63 @@ +{% extends '@RoadizRozier/login/base.html.twig' %} + +{% block title %}{% trans %}login{% endtrans %} | {{ parent() }}{% endblock %} + +{% block login_content %} + {% if error %} +
+ {{- error.messageKey|default(error.message)|trans(error.messageData, 'security') -}} +
+ {% endif %} + + {% if openid %} +

+ + + {{ (openid_button_label|default('login_with_openid'))|trans -}} + +

+
+ {% endif %} + +
+
+
+ + +
+
+ + +
+
+
+
+ +
+ +
+
+{% endblock %} diff --git a/lib/RoadizRozierBundle/templates/simple.html.twig b/lib/RoadizRozierBundle/templates/simple.html.twig new file mode 100644 index 00000000..73741a77 --- /dev/null +++ b/lib/RoadizRozierBundle/templates/simple.html.twig @@ -0,0 +1,51 @@ +{% set formattedLocale = app.request.locale|replace({'_': '-'})|lower %} + + + + + + {% block title %}{% trans %}back_office{% endtrans %}{% endblock %} + + + {% include '@RoadizRozier/admin/meta-icon.html.twig' %} + {# CSS scripts inclusions / Using webpack #} + {% include '@RoadizRozier/partials/css-inject.html.twig' %} + + + + +{% include '@RoadizRozier/includes/messages.html.twig' %} +
+ +
+
+ {% block content %} +

{% trans %}welcome{% endtrans %}

+ {% endblock %} +
+
+ +{% if head.devMode %} + +{% else %} + +{% endif %} +{# JS scripts inclusions / Using webpack #} +{% include '@RoadizRozier/partials/simple-js-inject.html.twig' %} +{% block customScripts %}{% endblock %} + + diff --git a/lib/RoadizRozierBundle/templates/users/list.html.twig b/lib/RoadizRozierBundle/templates/users/list.html.twig new file mode 100644 index 00000000..cbea15f0 --- /dev/null +++ b/lib/RoadizRozierBundle/templates/users/list.html.twig @@ -0,0 +1,115 @@ +{% extends '@RoadizRozier/layout.html.twig' %} + +{% block title %}{% trans %}users{% endtrans %} | {{ parent() }}{% endblock %} + +{% block content %} +
+
+

{% trans %}users{% endtrans %}

+ {% include '@RoadizRozier/widgets/countFiltersBar.html.twig' %} + +
+ + {% include '@RoadizRozier/widgets/filtersBar.html.twig' %} +
+
+ + + + + + + + + + + + + + {% for user in users %} + + + + + + + + + + {% endfor %} + +
{% trans %}user.username{% endtrans %} + {% include '@RoadizRozier/includes/column_ordering.html.twig' with { + 'field': 'username', + 'filters': filters, + } only %} + {% trans %}user.email{% endtrans %} + {% include '@RoadizRozier/includes/column_ordering.html.twig' with { + 'field': 'email', + 'filters': filters, + } only %} + {% trans %}user.lastLogin{% endtrans %} + {% include '@RoadizRozier/includes/column_ordering.html.twig' with { + 'field': 'lastLogin', + 'filters': filters, + } only %} + {% trans %}user.roles{% endtrans %}{% trans %}user.status{% endtrans %}{% trans %}actions{% endtrans %}
+ {{ user.identifier }} + + {% if is_granted('ROLE_SUPERADMIN') or not user.SuperAdmin %} + + {{- user.username|u.truncate(30) -}} + + {% else %} + {{- user.username|u.truncate(30) -}} + {% endif %} + {{ user.email|u.truncate(30) }} + {% if user.lastLogin %} + {{ user.lastLogin|format_datetime }} + {% else %} + {% trans %}user.neverLoggedIn{% endtrans %} + {% endif %} + {% transchoice user.roles|length %}{0} no.role|{1} 1.role|]1,Inf] %count%.roles{% endtranschoice %} + {% if user.enabled %} +
+ +
+ {% else %} +
+ +
+ {% endif %} + {% if not user.accountNonLocked %} +
+ +
+ {% endif %} + {% if not user.credentialsNonExpired or not user.accountNonExpired %} +
+ +
+ {% endif %} +
+ {% apply spaceless %} + {% if is_granted('ROLE_SUPERADMIN') or not user.SuperAdmin %} + + {% if not (user.username == app.user.username or is_granted('IS_IMPERSONATOR')) %} + + {% endif %} + {% if is_granted('ROLE_ACCESS_USERS_DELETE') %} + + {% endif %} + {% endif %} + {% endapply %} +
+
+
+
+{% endblock %} diff --git a/lib/RoadizRozierBundle/translations/attributes/messages.ar.xlf b/lib/RoadizRozierBundle/translations/attributes/messages.ar.xlf new file mode 100644 index 00000000..eda82b67 --- /dev/null +++ b/lib/RoadizRozierBundle/translations/attributes/messages.ar.xlf @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/lib/RoadizRozierBundle/translations/attributes/messages.de.xlf b/lib/RoadizRozierBundle/translations/attributes/messages.de.xlf new file mode 100644 index 00000000..af1315f4 --- /dev/null +++ b/lib/RoadizRozierBundle/translations/attributes/messages.de.xlf @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/lib/RoadizRozierBundle/translations/attributes/messages.en.xlf b/lib/RoadizRozierBundle/translations/attributes/messages.en.xlf new file mode 100644 index 00000000..3a80ce2e --- /dev/null +++ b/lib/RoadizRozierBundle/translations/attributes/messages.en.xlf @@ -0,0 +1,317 @@ + + + + + + manage.attributes + Manage attributes + + + attributes + Attributes + + + attributes.code + Code + + + add.a.attribute + Create an attribute + + + attributes.form.code + Code + + + attributes.form.type + Attribute type + + + attributes.form.attributeTranslations + Translations + + + attributes.form.options + Available options (for choice type) + + + edit.attribute.%name% + %name% + + + back_to.attributes + Back to attributes + + + attributes.form.type.string + Basic text string + + + attributes.form.type.datetime + Date and time + + + attributes.form.type.boolean + Boolean (true of false) + + + attributes.form.type.integer + Integer number + + + attributes.form.type.decimal + Decimal number + + + attributes.form.type.percent + Percent + + + attributes.form.type.email + Single email address + + + attributes.form.type.colour + Hexadecimal colour + + + attributes.form.type.enum + Single choice + + + attributes.form.type.date + Date + + + attributes.form.type.country + Country code + + + node.attributes + Attributes + + + attribute_values.form.attribute + Add a new attribute + + + new_attribute + -- Choose an attribute -- + + + add.a.node.attribute + Add attribute to current node + + + attributes.no_value + -- No value -- + + + attribute.%name%.created + Attribute %name% was created + + + attribute.%name%.updated + Attribute %name% was updated + + + attribute.%name%.deleted + Attribute %name% was deleted + + + attributes.value + Value + + + attributes.group + Group + + + are_you_sure.delete.attribute + Are you sure you want to delete this attribute? + + + attribute.%name%.deleted_from_node.%nodeName% + Attribute %name% was deleted from %nodeName% node + + + delete.attribute.%name% + Delete attribute %name% + + + attribute_value_translation.%name%.updated_from_node.%nodeName% + Attribute %name%'s value has been updated for node-source %nodeName%. + + + attribute_code.must_contain_alpha_underscore + Attribute code must only contain alphabetical characters including underscore. + Validation failed message on Attribute code that must only contain alpha and underscore characters. + + + attributes.form.searchable + Indexed in search-engine? + + + + attributes.form_help.searchable + Index current attribute values in your full-text search-engine for each translation. + + + + attributes.form_help.code + Code allows attribute name to be unique all over your website translations. + + + + attributes.import_form.file.label + Import attributes file + + + + attributes.import_form.file.help + Attributes file must be a valid JSON file with Roadiz attributes structure. + + + + attributes.form.group + Group + + + + attributes.form_help.group + Groups allow developer to gather attributes in same place for public display. + + + + attributes.form.color + Color + + + + attributes.form_help.color + Attribute color that may be used on public display. + + + + attributes.form.documents + Documents + + + + attributes.form_help.documents + Documents that may illustrate current attribute on public display. + + + + attributes.form.group.placeholder + -- No group -- + + + + back_to.attribute_groups + Back to attribute groups + + + + add.a.attribute_group + Create an attribute group + + + + attribute_groups + Attribute groups + + + + attribute_groups.name + Group name + + + + attribute_group.form.name + Group name + + + + attribute_group.%name%.created + %name% attribute group was created + + + + attribute_group.%name%.updated + %name% attribute group was updated + + + + attribute_group.%name%.deleted + %name% attribute group was deleted + + + + delete.attribute_group.%name% + Delete %name% attribute group + + + + are_you_sure.delete.attribute_group + Are you sure you want to delete this attribute group? + + + + attribute_group.form.canonicalName + Canonical name + + + + attribute_group.form.attributeGroupTranslations + Group translations + + + + attributes.reset_value + Reset attribute value to default language + Action label to reset value to the default language one. This does not delete attribute, only its translation. + + + attribute.%name%.reset_for_node.%nodeName% + %name% attribute has been reset for node %nodeName%. + + + + attributes.delete_value + Delete attribute value for this node + + + + are_you_sure.delete_value.attribute + Are you sure you want to delete this attribute value for this node? + + + + delete.attribute.%name%.for_node.%nodeName% + Delete %name% attribute for %nodeName% node + + + + reset.attribute.%name% + Reset %name% attribute value + + + + reset.attribute.%name%.for_node.%nodeName% + Reset %name% attribute value for %nodeName% node + + + + are_you_sure.reset.attribute_value + Are you sure you want to reset attribute value for this language? + + + + attribute_value.%attributeValueId%.not_exists + Attribute %attributeValueId% value does not exist + + + + + diff --git a/lib/RoadizRozierBundle/translations/attributes/messages.es.xlf b/lib/RoadizRozierBundle/translations/attributes/messages.es.xlf new file mode 100644 index 00000000..80508402 --- /dev/null +++ b/lib/RoadizRozierBundle/translations/attributes/messages.es.xlf @@ -0,0 +1,11 @@ + + + + + + attributes + Atributos + + + + diff --git a/lib/RoadizRozierBundle/translations/attributes/messages.fr.xlf b/lib/RoadizRozierBundle/translations/attributes/messages.fr.xlf new file mode 100644 index 00000000..da63fa3d --- /dev/null +++ b/lib/RoadizRozierBundle/translations/attributes/messages.fr.xlf @@ -0,0 +1,317 @@ + + + + + + manage.attributes + Attributs + + + attributes + Attributs + + + attributes.code + Code + + + add.a.attribute + Créer un attribut + + + attributes.form.code + Code d'attribut + + + attributes.form.type + Type de l'attribut + + + attributes.form.attributeTranslations + Traductions + + + attributes.form.options + Options (pour le type «Choix») + + + edit.attribute.%name% + %name% + + + back_to.attributes + Retour aux attributs + + + attributes.form.type.string + Chaîne de caractères + + + attributes.form.type.datetime + Date et heure + + + attributes.form.type.boolean + Booléen (vrai ou faux) + + + attributes.form.type.integer + Nombre entier + + + attributes.form.type.decimal + Nombre décimal + + + attributes.form.type.percent + Pourcentage + + + attributes.form.type.email + Adresse email unique + + + attributes.form.type.colour + Couleur hexadécimale + + + attributes.form.type.enum + Choix simple + + + attributes.form.type.date + Date + + + attributes.form.type.country + Code pays + + + node.attributes + Attributs + + + attribute_values.form.attribute + Ajouter un nouvel attribut + + + new_attribute + -- Choisir un attribut -- + + + add.a.node.attribute + Ajouter l'attribut au nœud + + + attributes.no_value + -- Aucune valeur -- + + + attribute.%name%.created + L'attribut %name% a été créé. + + + attribute.%name%.updated + L'attribut %name% a été mis à jour. + + + attribute.%name%.deleted + L'attribut %name% a été supprimé. + + + attributes.value + Valeur + + + attributes.group + Groupe + + + are_you_sure.delete.attribute + Êtes-vous sûr·e de vouloir supprimer cet attribut ? + + + attribute.%name%.deleted_from_node.%nodeName% + L'attribut %name% a été supprimé du nœud %nodeName%. + + + delete.attribute.%name% + Supprimer l'attribut %name% + + + attribute_value_translation.%name%.updated_from_node.%nodeName% + La valeur de l'attribut %name% a été mis à jour pour le nœud %nodeName%. + + + attribute_code.must_contain_alpha_underscore + Un code d'attribut ne doit contenir seulement des lettres et le caractère tiret-bas. + Validation failed message on Attribute code that must only contain alpha and underscore characters. + + + attributes.form.searchable + Indexé dans le moteur de recherche ? + + + + attributes.form_help.searchable + Indexe la valeur de l'attribut dans le moteur de recherche plein-texte. + + + + attributes.form_help.code + Le code d'attribut permet de l'identifier de manière unique et ce pour toutes les traductions de votre site. + + + + attributes.import_form.file.label + Importer un fichier d’attributs + + + + attributes.import_form.file.help + Le fichier d’attributs doit être un fichier au format JSON valide respectant la structure des attributs Roadiz. + + + + attributes.form.group + Groupe + + + + attributes.form_help.group + Permet de rassembler les attributs au sein de groupes pour l’affichage publique. + + + + attributes.form.color + Couleur + + + + attributes.form_help.color + Couleur de l’attribut pouvant être affichée publiquement. + + + + attributes.form.documents + Documents + + + + attributes.form_help.documents + Documents illustrant l’attribut pour son affichage public. + + + + attributes.form.group.placeholder + -- Aucun groupe -- + + + + back_to.attribute_groups + Retour aux groupes d’attributs + + + + add.a.attribute_group + Créer un groupe d’attributs + + + + attribute_groups + Groupes d’attributs + + + + attribute_groups.name + Nom du groupe + + + + attribute_group.form.name + Nom du groupe + + + + attribute_group.%name%.created + Le groupe %name% a bien été créé + + + + attribute_group.%name%.updated + Le groupe %name% a bien été mis à jour + + + + attribute_group.%name%.deleted + Le groupe %name% a bien été supprimé + + + + delete.attribute_group.%name% + Supprimer le groupe d’attribut %name% + + + + are_you_sure.delete.attribute_group + Êtes-vous sûr·e de vouloir supprimer le groupe d'attributs ? + + + + attribute_group.form.canonicalName + Nom canonique + + + + attribute_group.form.attributeGroupTranslations + Traductions du groupe + + + + attributes.reset_value + Réinitialiser la valeur à celle de la langue principale + Action label to reset value to the default language one. This does not delete attribute, only its translation. + + + attribute.%name%.reset_for_node.%nodeName% + L'attribut %name% a été réinitialisé pour le nœud %nodeName%. + + + + attributes.delete_value + Supprimer la valeur pour ce nœud + + + + are_you_sure.delete_value.attribute + Êtes-vous sûr·e de vouloir supprimer la valeur de cet attribut pour ce nœud ? + + + + delete.attribute.%name%.for_node.%nodeName% + Supprimer l’attribut «%name%» pour le nœud «%nodeName%» + + + + reset.attribute.%name% + Réinitialiser la valeur de l’attribut %name% + + + + reset.attribute.%name%.for_node.%nodeName% + Réinitialiser la valeur de l’attribut «%name%» pour le nœud «%nodeName%» + + + + are_you_sure.reset.attribute_value + Êtes-vous sûr·e de vouloir réinitialiser la valeur de l'attribut pour cette langue ? + + + + attribute_value.%attributeValueId%.not_exists + La valeur de l’attribut %attributeValueId% n’existe pas + + + + + diff --git a/lib/RoadizRozierBundle/translations/attributes/messages.id.xlf b/lib/RoadizRozierBundle/translations/attributes/messages.id.xlf new file mode 100644 index 00000000..1b3d457c --- /dev/null +++ b/lib/RoadizRozierBundle/translations/attributes/messages.id.xlf @@ -0,0 +1,317 @@ + + + + + + manage.attributes + Kelola atribut + + + attributes + Atribut + + + attributes.code + Kode + + + add.a.attribute + Buat sebuah atribut + + + attributes.form.code + Kode + + + attributes.form.type + Jenis atribut + + + attributes.form.attributeTranslations + Terjemahan + + + attributes.form.options + Opsi yang tersisa (untuk jenis pilihan) + + + edit.attribute.%name% + %name% + + + back_to.attributes + Kembali ke atrinut + + + attributes.form.type.string + String teks dasar + + + attributes.form.type.datetime + Tanggal dan waktu + + + attributes.form.type.boolean + Boolean (benar atau salah) + + + attributes.form.type.integer + Nomor integer + + + attributes.form.type.decimal + Nomor desimal + + + attributes.form.type.percent + Persen + + + attributes.form.type.email + Alamat email tunggal + + + attributes.form.type.colour + Warna heksadesimal + + + attributes.form.type.enum + Pilihan tunggal + + + attributes.form.type.date + Tanggal + + + attributes.form.type.country + Kode negara + + + node.attributes + Atrinut + + + attribute_values.form.attribute + Tambah sebuah atribut baru + + + new_attribute + -- Pilih sebuah atribut -- + + + add.a.node.attribute + Tambah atribut ke node saat ini + + + attributes.no_value + -- Tidak ada nilai -- + + + attribute.%name%.created + Atribut %name% telah dibuat + + + attribute.%name%.updated + Atribut %name% telah diperbarui + + + attribute.%name%.deleted + Atribut %name% telah dihapus + + + attributes.value + Nilai + + + attributes.group + Grup + + + are_you_sure.delete.attribute + Apakah Anda yakin ingin menghapus atribut ini? + + + attribute.%name%.deleted_from_node.%nodeName% + Atribut %name% telah dihapus dari node %nodeName% + + + delete.attribute.%name% + Hapus atribut %name% + + + attribute_value_translation.%name%.updated_from_node.%nodeName% + Nilai atribut %name% tlah diperbarui dari sumber-node %nodeName%. + + + attribute_code.must_contain_alpha_underscore + Kode atribut harus berisi hanya karakter alphabet termasuk garis bawah. + Validation failed message on Attribute code that must only contain alpha and underscore characters. + + + attributes.form.searchable + Diindeks dalam mesin-pencari? + + + + attributes.form_help.searchable + Indeks nilai atribut saat ini dalam mesin-pencari teks-penuh untuk setiap terjemahan. + + + + attributes.form_help.code + Kode mengizinkan nama atribut untuk menjadi unik di seluruh terjemahan situs web Anda. + + + + attributes.import_form.file.label + Impor berkas atribut + + + + attributes.import_form.file.help + Berkas atribut haruslah berupa sebuah bekas JSON yang valid dengan struktur atribut Roadiz. + + + + attributes.form.group + Grup + + + + attributes.form_help.group + Grup mengizinkan pengembang untuk mengizinkan mengumpulkan atribut dalam tempat yang sama untuk tampilan publik. + + + + attributes.form.color + Warna + + + + attributes.form_help.color + Warna atribut yang mungkin digunakan pada tampilan publik. + + + + attributes.form.documents + Dokumen + + + + attributes.form_help.documents + Dokumen yang mungkin menggambarkan atribut saat ini pada tampilan publik. + + + + attributes.form.group.placeholder + -- Tidak ada grup -- + + + + back_to.attribute_groups + Kembali ke grup atribut + + + + add.a.attribute_group + Buat sebuah grup atribut + + + + attribute_groups + Grup atribut + + + + attribute_groups.name + Nama grup + + + + attribute_group.form.name + Nama grup + + + + attribute_group.%name%.created + Grup atribut %name% telah dibuat + + + + attribute_group.%name%.updated + Grup atribut %name% telah diperbarui + + + + attribute_group.%name%.deleted + Grup atribut %name% telah dihapus + + + + delete.attribute_group.%name% + Hapus grup atribut %name% + + + + are_you_sure.delete.attribute_group + Apakah Anda yakin ingin menghapus grup atribut ini? + + + + attribute_group.form.canonicalName + Nama kanonikal + + + + attribute_group.form.attributeGroupTranslations + Terjemahan grup + + + + attributes.reset_value + Hapus nilai atribut ke bahasa bawaan + Action label to reset value to the default language one. This does not delete attribute, only its translation. + + + attribute.%name%.reset_for_node.%nodeName% + %name% atribut telah di atur ulang/reset untuk node %nodeName%. + + + + attributes.delete_value + Hapus nilai atribut untuk node ini + + + + are_you_sure.delete_value.attribute + Apakah Anda yakin ingin menghapus nilai atribut ini untuk node ini? + + + + delete.attribute.%name%.for_node.%nodeName% + Hapus %name% atribut untuk node %nodeName% + + + + reset.attribute.%name% + Atur ulang nilai atribut %name% + + + + reset.attribute.%name%.for_node.%nodeName% + Atur ulang nilai %name% atribut untuk node %nodeName% + + + + are_you_sure.reset.attribute_value + Apakah Anda yakin ingin mengatur ulang/reset nilai atribut untuk bahasa ini? + + + + attribute_value.%attributeValueId%.not_exists + Nilai atribut %attributeValueId% tidak ada/tersedia + + + + + diff --git a/lib/RoadizRozierBundle/translations/attributes/messages.it.xlf b/lib/RoadizRozierBundle/translations/attributes/messages.it.xlf new file mode 100644 index 00000000..09098f4e --- /dev/null +++ b/lib/RoadizRozierBundle/translations/attributes/messages.it.xlf @@ -0,0 +1,11 @@ + + + + + + attributes + Attributi + + + + diff --git a/lib/RoadizRozierBundle/translations/attributes/messages.ru.xlf b/lib/RoadizRozierBundle/translations/attributes/messages.ru.xlf new file mode 100644 index 00000000..3502c66c --- /dev/null +++ b/lib/RoadizRozierBundle/translations/attributes/messages.ru.xlf @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/lib/RoadizRozierBundle/translations/attributes/messages.sr.xlf b/lib/RoadizRozierBundle/translations/attributes/messages.sr.xlf new file mode 100644 index 00000000..0905e380 --- /dev/null +++ b/lib/RoadizRozierBundle/translations/attributes/messages.sr.xlf @@ -0,0 +1,159 @@ + + + + + + manage.attributes + Уређивање својстава + + + attributes + својства + + + attributes.code + Код + + + add.a.attribute + Креирај својство + + + attributes.form.code + Код својства + + + attributes.form.type + Тип својства + + + attributes.form.attributeTranslations + Преводи + + + attributes.form.options + Доступне опције (за тип) + + + edit.attribute.%name% + %name% + + + back_to.attributes + Назад на својства + + + attributes.form.type.string + Низ карактера + + + attributes.form.type.datetime + Датум и време + + + attributes.form.type.boolean + Логичко (да или не) + + + attributes.form.type.integer + Цео број + + + attributes.form.type.decimal + Децимални број + + + attributes.form.type.email + Јединствена email адреса + + + attributes.form.type.colour + Боја, хексадецимално + + + attributes.form.type.enum + Јединствени избор + + + attributes.form.type.date + Датум + + + attributes.form.type.country + Код државе + + + node.attributes + Својства + + + attribute_values.form.attribute + Додај ново својство + + + new_attribute + -- Одаберите својство -- + + + add.a.node.attribute + Доделите својство постојећем чвору + + + attributes.no_value + -- Нема задате вредности -- + + + attribute.%name%.created + Својство %name% је креирано + + + attribute.%name%.updated + Својство %name% је ажурирано + + + attribute.%name%.deleted + Својство %name% је избрисано + + + attributes.value + Вредност + + + are_you_sure.delete.attribute + Да ли сте сигурни да желите да избришете ово својство? + + + attribute.%name%.deleted_from_node.%nodeName% + Својство %name% је избрисано из чвора %nodeName% + + + delete.attribute.%name% + Избриши својство %name% + + + attribute_value_translation.%name%.updated_from_node.%nodeName% + Вредност својства %name% за чвор %nodeName% је ажурирана. + + + attribute_code.must_contain_alpha_underscore + Код атрибута може садржати само алфабетске знакове, укључујући и доњу црту. + Validation failed message on Attribute code that must only contain alpha and underscore characters. + + + attributes.form.searchable + Индексирaнo у енџин за претраживање? + + + + attributes.form_help.searchable + Индексирај тренутне вредности атрибута, у енџин за претраживање, за сваки превод. + + + + attributes.form_help.code + Код дозвољава да име атрибута буде јединствено, на целом веб-сајту, за све преводе. + + + + + diff --git a/lib/RoadizRozierBundle/translations/attributes/messages.tr.xlf b/lib/RoadizRozierBundle/translations/attributes/messages.tr.xlf new file mode 100644 index 00000000..59286820 --- /dev/null +++ b/lib/RoadizRozierBundle/translations/attributes/messages.tr.xlf @@ -0,0 +1,31 @@ + + + + + + attributes.form.type.country + ülke türü özellikler + + + new_attribute + yeni özellik + + + attribute.%name%.created + %name% gurubu oluşturuldu. + + + attribute.%name%.updated + %name% gurubu güncellendi. + + + attribute.%name%.deleted + %name% gurubu kaldırıldı. + + + are_you_sure.delete.attribute + Bunu kaldırmak istediğine emin misin? + + + + diff --git a/lib/RoadizRozierBundle/translations/attributes/messages.uk.xlf b/lib/RoadizRozierBundle/translations/attributes/messages.uk.xlf new file mode 100644 index 00000000..10b2f9e1 --- /dev/null +++ b/lib/RoadizRozierBundle/translations/attributes/messages.uk.xlf @@ -0,0 +1,11 @@ + + + + + + manage.attributes + управління атрибутами + + + + diff --git a/lib/RoadizRozierBundle/translations/attributes/messages.xlf b/lib/RoadizRozierBundle/translations/attributes/messages.xlf new file mode 100644 index 00000000..caea664c --- /dev/null +++ b/lib/RoadizRozierBundle/translations/attributes/messages.xlf @@ -0,0 +1,321 @@ + + + + + + manage.attributes + + + + attributes + + + + attributes.code + + + + add.a.attribute + + + + attributes.form.code + + + + attributes.form.type + + + + attributes.form.attributeTranslations + + + + attributes.form.options + + + + edit.attribute.%name% + + + + back_to.attributes + + + + attributes.form.type.string + + + + attributes.form.type.datetime + + + + attributes.form.type.boolean + + + + attributes.form.type.integer + + + + attributes.form.type.decimal + + + + attributes.form.type.percent + + + + attributes.form.type.email + + + + attributes.form.type.colour + + + + attributes.form.type.enum + + + + attributes.form.type.date + + + + attributes.form.type.country + + + + + node.attributes + + + + attribute_values.form.attribute + + + + new_attribute + + + + add.a.node.attribute + + + + attributes.no_value + + + + attribute.%name%.created + + + + attribute.%name%.updated + + + + attribute.%name%.deleted + + + + attributes.value + + + + attributes.group + + + + are_you_sure.delete.attribute + + + + attribute.%name%.deleted_from_node.%nodeName% + + + + delete.attribute.%name% + + + + attribute_value_translation.%name%.updated_from_node.%nodeName% + + + + attribute_code.must_contain_alpha_underscore + + Validation failed message on Attribute code that must only contain alpha and underscore characters. + + + + attributes.form.searchable + + + + + attributes.form_help.searchable + + + + + attributes.form_help.code + + + + + attributes.import_form.file.label + + + + + attributes.import_form.file.help + + + + + attributes.form.group + + + + + attributes.form_help.group + + + + + attributes.form.color + + + + + attributes.form_help.color + + + + + attributes.form.documents + + + + + attributes.form_help.documents + + + + + attributes.form.group.placeholder + + + + + back_to.attribute_groups + + + + + add.a.attribute_group + + + + + attribute_groups + + + + + attribute_groups.name + + + + + attribute_group.form.name + + + + + attribute_group.%name%.created + + + + + attribute_group.%name%.updated + + + + + attribute_group.%name%.deleted + + + + + delete.attribute_group.%name% + + + + + are_you_sure.delete.attribute_group + + + + + attribute_group.form.canonicalName + + + + + attribute_group.form.attributeGroupTranslations + + + + + + attributes.reset_value + + Action label to reset value to the default language one. This does not delete attribute, only its translation. + + + attribute.%name%.reset_for_node.%nodeName% + + + + + + attributes.delete_value + + + + + are_you_sure.delete_value.attribute + + + + + delete.attribute.%name%.for_node.%nodeName% + + + + + reset.attribute.%name% + + + + + reset.attribute.%name%.for_node.%nodeName% + + + + + are_you_sure.reset.attribute_value + + + + + attribute_value.%attributeValueId%.not_exists + + + + + + diff --git a/lib/RoadizRozierBundle/translations/attributes/messages.zh.xlf b/lib/RoadizRozierBundle/translations/attributes/messages.zh.xlf new file mode 100644 index 00000000..8811922f --- /dev/null +++ b/lib/RoadizRozierBundle/translations/attributes/messages.zh.xlf @@ -0,0 +1,317 @@ + + + + + + manage.attributes + 管理属性 + + + attributes + 属性 + + + attributes.code + 代码 + + + add.a.attribute + 创建一个属性 + + + attributes.form.code + 代码 + + + attributes.form.type + 属性类型 + + + attributes.form.attributeTranslations + 翻译 + + + attributes.form.options + 可用的选项(选择类型) + + + edit.attribute.%name% + %name% + + + back_to.attributes + 回到属性 + + + attributes.form.type.string + 基本文字字符串 + + + attributes.form.type.datetime + 日期与时间 + + + attributes.form.type.boolean + 布尔值(是 或 否) + + + attributes.form.type.integer + 整数 + + + attributes.form.type.decimal + 小数 + + + attributes.form.type.percent + 百分比 + + + attributes.form.type.email + 单个电子邮件地址 + + + attributes.form.type.colour + 十六进制颜色 + + + attributes.form.type.enum + 单个选择 + + + attributes.form.type.date + 日期 + + + attributes.form.type.country + 国家及地区代码 + + + node.attributes + 属性 + + + attribute_values.form.attribute + 添加一个新属性 + + + new_attribute + -- 选择一个属性 -- + + + add.a.node.attribute + 添加属性到现有代码 + + + attributes.no_value + -- 没有值 -- + + + attribute.%name%.created + 属性 %name% 已创建 + + + attribute.%name%.updated + 属性 %name% 已更新 + + + attribute.%name%.deleted + 属性 %name% 已删除 + + + attributes.value + + + + attributes.group + 群组 + + + are_you_sure.delete.attribute + 您确定要删除此属性吗? + + + attribute.%name%.deleted_from_node.%nodeName% + 属性 %name% 已从节点 %nodeName% 中删除 + + + delete.attribute.%name% + 删除属性 %name% + + + attribute_value_translation.%name%.updated_from_node.%nodeName% + 节点源 %nodeName% 的属性 %name% 的值已更新。 + + + attribute_code.must_contain_alpha_underscore + 属性代码应只包含字母及下划线。 + Validation failed message on Attribute code that must only contain alpha and underscore characters. + + + attributes.form.searchable + 在搜索引擎中索引? + + + + attributes.form_help.searchable + 在全文搜索引擎中,为每个翻译索引目前的属性值。 + + + + attributes.form_help.code + 代码能让属性名在您的网站翻译中是唯一的。 + + + + attributes.import_form.file.label + 导入属性文件 + + + + attributes.import_form.file.help + 属性文件必须是包含 Roadiz 属性结构的有效 JSON 文件。 + + + + attributes.form.group + 群组 + + + + attributes.form_help.group + 群组能让开发者在同一个位置收集属性以公开展示。 + + + + attributes.form.color + 颜色 + + + + attributes.form_help.color + 可能公开展示的属性颜色 + + + + attributes.form.documents + 文档 + + + + attributes.form_help.documents + 可能会公开展示的说明现有属性的文档 + + + + attributes.form.group.placeholder + -- 没有群组 -- + + + + back_to.attribute_groups + 回到属性组 + + + + add.a.attribute_group + 创建一个属性组 + + + + attribute_groups + 属性组 + + + + attribute_groups.name + 群组名 + + + + attribute_group.form.name + 群组名 + + + + attribute_group.%name%.created + %name% 属性组已创建 + + + + attribute_group.%name%.updated + %name% 属性组已更新 + + + + attribute_group.%name%.deleted + %name% 属性组已删除 + + + + delete.attribute_group.%name% + 删除 %name% 属性组 + + + + are_you_sure.delete.attribute_group + 您确定要删除此属性组吗? + + + + attribute_group.form.canonicalName + 规范名称 + + + + attribute_group.form.attributeGroupTranslations + 群组翻译 + + + + attributes.reset_value + 重置属性值为默认语言 + Action label to reset value to the default language one. This does not delete attribute, only its translation. + + + attribute.%name%.reset_for_node.%nodeName% + 节点 %nodeName% 的属性 %name% 已被重置。 + + + + attributes.delete_value + 删除此节点的属性值 + + + + are_you_sure.delete_value.attribute + 您确定要删除此节点的属性值吗? + + + + delete.attribute.%name%.for_node.%nodeName% + 删除节点 %nodeName% 的 %name% 属性。 + + + + reset.attribute.%name% + 重置 %name% 属性值 + + + + reset.attribute.%name%.for_node.%nodeName% + 重置 %nodeName% 节点的 %name% 属性值 + + + + are_you_sure.reset.attribute_value + 您确定要重置此语言的属性值吗? + + + + attribute_value.%attributeValueId%.not_exists + 属性 %attributeValueId% 的值不存在 + + + + + diff --git a/lib/RoadizRozierBundle/translations/custom-forms/messages.ar.xlf b/lib/RoadizRozierBundle/translations/custom-forms/messages.ar.xlf new file mode 100644 index 00000000..83fd1d3c --- /dev/null +++ b/lib/RoadizRozierBundle/translations/custom-forms/messages.ar.xlf @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/lib/RoadizRozierBundle/translations/custom-forms/messages.de.xlf b/lib/RoadizRozierBundle/translations/custom-forms/messages.de.xlf new file mode 100644 index 00000000..bc48de7c --- /dev/null +++ b/lib/RoadizRozierBundle/translations/custom-forms/messages.de.xlf @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/lib/RoadizRozierBundle/translations/custom-forms/messages.en.xlf b/lib/RoadizRozierBundle/translations/custom-forms/messages.en.xlf new file mode 100644 index 00000000..57a07a85 --- /dev/null +++ b/lib/RoadizRozierBundle/translations/custom-forms/messages.en.xlf @@ -0,0 +1,62 @@ + + + + + + custom-form.usage + Usage + Custom form nav-bar item "Usage" label + + + customForm.retentionTime + Answers retention time + + + + customForm.retentionTime.help + By setting a retention policy, responses will be automatically deleted along with their associated data. Make sure that attached documents are not used on your site. + + + + customForm.retentionTime.always + -- Unlimited time -- + + + + customForm.retentionTime.one_week + 1 week + + + + customForm.retentionTime.two_weeks + 2 weeks + + + + customForm.retentionTime.one_month + 1 month + + + + customForm.retentionTime.three_months + 3 months + + + + customForm.retentionTime.six_months + 6 months + + + + customForm.retentionTime.one_year + 1 year + + + + customForm.retentionTime.two_years + 2 year + + + + + diff --git a/lib/RoadizRozierBundle/translations/custom-forms/messages.es.xlf b/lib/RoadizRozierBundle/translations/custom-forms/messages.es.xlf new file mode 100644 index 00000000..28f25ba7 --- /dev/null +++ b/lib/RoadizRozierBundle/translations/custom-forms/messages.es.xlf @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/lib/RoadizRozierBundle/translations/custom-forms/messages.fr.xlf b/lib/RoadizRozierBundle/translations/custom-forms/messages.fr.xlf new file mode 100644 index 00000000..482df27c --- /dev/null +++ b/lib/RoadizRozierBundle/translations/custom-forms/messages.fr.xlf @@ -0,0 +1,62 @@ + + + + + + custom-form.usage + Utilisation + Custom form nav-bar item "Usage" label + + + customForm.retentionTime + Durée de conservation des réponses + + + + customForm.retentionTime.help + En définissant une politique de conservation, les réponses seront automatiquements supprimées ainsi que leur données associées. Assurez-vous que les documents attachés ne sont pas utilisés sur votre site. + + + + customForm.retentionTime.always + -- Durée illimitée -- + + + + customForm.retentionTime.one_week + 1 semaine + + + + customForm.retentionTime.two_weeks + 2 semaines + + + + customForm.retentionTime.one_month + 1 mois + + + + customForm.retentionTime.three_months + 3 mois + + + + customForm.retentionTime.six_months + 6 mois + + + + customForm.retentionTime.one_year + 1 an + + + + customForm.retentionTime.two_years + 2 ans + + + + + diff --git a/lib/RoadizRozierBundle/translations/custom-forms/messages.id.xlf b/lib/RoadizRozierBundle/translations/custom-forms/messages.id.xlf new file mode 100644 index 00000000..2efeed85 --- /dev/null +++ b/lib/RoadizRozierBundle/translations/custom-forms/messages.id.xlf @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/lib/RoadizRozierBundle/translations/custom-forms/messages.it.xlf b/lib/RoadizRozierBundle/translations/custom-forms/messages.it.xlf new file mode 100644 index 00000000..df60c52f --- /dev/null +++ b/lib/RoadizRozierBundle/translations/custom-forms/messages.it.xlf @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/lib/RoadizRozierBundle/translations/custom-forms/messages.ru.xlf b/lib/RoadizRozierBundle/translations/custom-forms/messages.ru.xlf new file mode 100644 index 00000000..e7c99a61 --- /dev/null +++ b/lib/RoadizRozierBundle/translations/custom-forms/messages.ru.xlf @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/lib/RoadizRozierBundle/translations/custom-forms/messages.sr.xlf b/lib/RoadizRozierBundle/translations/custom-forms/messages.sr.xlf new file mode 100644 index 00000000..aad9d02f --- /dev/null +++ b/lib/RoadizRozierBundle/translations/custom-forms/messages.sr.xlf @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/lib/RoadizRozierBundle/translations/custom-forms/messages.tr.xlf b/lib/RoadizRozierBundle/translations/custom-forms/messages.tr.xlf new file mode 100644 index 00000000..83226edf --- /dev/null +++ b/lib/RoadizRozierBundle/translations/custom-forms/messages.tr.xlf @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/lib/RoadizRozierBundle/translations/custom-forms/messages.uk.xlf b/lib/RoadizRozierBundle/translations/custom-forms/messages.uk.xlf new file mode 100644 index 00000000..65e445ca --- /dev/null +++ b/lib/RoadizRozierBundle/translations/custom-forms/messages.uk.xlf @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/lib/RoadizRozierBundle/translations/custom-forms/messages.xlf b/lib/RoadizRozierBundle/translations/custom-forms/messages.xlf new file mode 100644 index 00000000..b9e93833 --- /dev/null +++ b/lib/RoadizRozierBundle/translations/custom-forms/messages.xlf @@ -0,0 +1,62 @@ + + + + + + custom-form.usage + + Custom form nav-bar item "Usage" label + + + customForm.retentionTime + + + + + customForm.retentionTime.help + + + + + customForm.retentionTime.always + + + + + customForm.retentionTime.one_week + + + + + customForm.retentionTime.two_weeks + + + + + customForm.retentionTime.one_month + + + + + customForm.retentionTime.three_months + + + + + customForm.retentionTime.six_months + + + + + customForm.retentionTime.one_year + + + + + customForm.retentionTime.two_years + + + + + + diff --git a/lib/RoadizRozierBundle/translations/custom-forms/messages.zh.xlf b/lib/RoadizRozierBundle/translations/custom-forms/messages.zh.xlf new file mode 100644 index 00000000..f5afb7c6 --- /dev/null +++ b/lib/RoadizRozierBundle/translations/custom-forms/messages.zh.xlf @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/lib/RoadizRozierBundle/translations/documents/messages.ar.xlf b/lib/RoadizRozierBundle/translations/documents/messages.ar.xlf new file mode 100644 index 00000000..774c7177 --- /dev/null +++ b/lib/RoadizRozierBundle/translations/documents/messages.ar.xlf @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/lib/RoadizRozierBundle/translations/documents/messages.de.xlf b/lib/RoadizRozierBundle/translations/documents/messages.de.xlf new file mode 100644 index 00000000..2c573554 --- /dev/null +++ b/lib/RoadizRozierBundle/translations/documents/messages.de.xlf @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/lib/RoadizRozierBundle/translations/documents/messages.en.xlf b/lib/RoadizRozierBundle/translations/documents/messages.en.xlf new file mode 100644 index 00000000..59f11176 --- /dev/null +++ b/lib/RoadizRozierBundle/translations/documents/messages.en.xlf @@ -0,0 +1,64 @@ + + + + + + document.externalUrl + External URL + + + document.limitations + Limitations + Documents limitations backoffice section label + + + document.copyrightHolder + Copyright holder + + + document.copyrightValidSince + Copyright valid since + + + document.copyrightValidUntil + Copyright valid until + + + no_document + No document + + + unused_documents.help + +Document are listed as “unused” when they are not linked to any node, tag, setting or attribute anymore. + +**Notice**: if you are using a Document URL directly in a Markdown text or from an external website, +*this document will be flagged as unused.* + + + all.documents + All documents + + + duplicated_documents + Duplicated documents + + + document.video_sources + All video sources + + + document.audio_sources + All audio sources + + + document.picture_sources + All picture sources + + + private_documents + Private documents + + + + diff --git a/lib/RoadizRozierBundle/translations/documents/messages.es.xlf b/lib/RoadizRozierBundle/translations/documents/messages.es.xlf new file mode 100644 index 00000000..b08cd2e0 --- /dev/null +++ b/lib/RoadizRozierBundle/translations/documents/messages.es.xlf @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/lib/RoadizRozierBundle/translations/documents/messages.fr.xlf b/lib/RoadizRozierBundle/translations/documents/messages.fr.xlf new file mode 100644 index 00000000..72f542ca --- /dev/null +++ b/lib/RoadizRozierBundle/translations/documents/messages.fr.xlf @@ -0,0 +1,64 @@ + + + + + + document.externalUrl + URL externe + + + document.limitations + Limitations + Documents limitations backoffice section label + + + document.copyrightHolder + Titulaire du droit d'auteur + + + document.copyrightValidSince + Le droit d’auteur est valide depuis + + + document.copyrightValidUntil + Le droit d’auteur est valide jusqu’au + + + no_document + Aucun document + + + unused_documents.help + +Les documents sont considérés comme inutilisés s'ils ne sont pas liés à un nœud, une étiquette, un paramètre général ou un attribut. + +**Attention** si vous utilisez directement l'URL de votre document : +dans un texte Markdown, un site externe, *le document sera considéré comme inutilisé.* + + + all.documents + Tous les documents + + + duplicated_documents + Doublons + + + document.video_sources + Toutes les sources vidéos + + + document.audio_sources + Toutes les sources audios + + + document.picture_sources + Toutes les sources images + + + private_documents + Documents privés + + + + diff --git a/lib/RoadizRozierBundle/translations/documents/messages.id.xlf b/lib/RoadizRozierBundle/translations/documents/messages.id.xlf new file mode 100644 index 00000000..c4736d59 --- /dev/null +++ b/lib/RoadizRozierBundle/translations/documents/messages.id.xlf @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/lib/RoadizRozierBundle/translations/documents/messages.it.xlf b/lib/RoadizRozierBundle/translations/documents/messages.it.xlf new file mode 100644 index 00000000..1fd967df --- /dev/null +++ b/lib/RoadizRozierBundle/translations/documents/messages.it.xlf @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/lib/RoadizRozierBundle/translations/documents/messages.ru.xlf b/lib/RoadizRozierBundle/translations/documents/messages.ru.xlf new file mode 100644 index 00000000..e61c5d05 --- /dev/null +++ b/lib/RoadizRozierBundle/translations/documents/messages.ru.xlf @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/lib/RoadizRozierBundle/translations/documents/messages.sr.xlf b/lib/RoadizRozierBundle/translations/documents/messages.sr.xlf new file mode 100644 index 00000000..feddb3ea --- /dev/null +++ b/lib/RoadizRozierBundle/translations/documents/messages.sr.xlf @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/lib/RoadizRozierBundle/translations/documents/messages.tr.xlf b/lib/RoadizRozierBundle/translations/documents/messages.tr.xlf new file mode 100644 index 00000000..871e1d0c --- /dev/null +++ b/lib/RoadizRozierBundle/translations/documents/messages.tr.xlf @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/lib/RoadizRozierBundle/translations/documents/messages.uk.xlf b/lib/RoadizRozierBundle/translations/documents/messages.uk.xlf new file mode 100644 index 00000000..7910382d --- /dev/null +++ b/lib/RoadizRozierBundle/translations/documents/messages.uk.xlf @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/lib/RoadizRozierBundle/translations/documents/messages.xlf b/lib/RoadizRozierBundle/translations/documents/messages.xlf new file mode 100644 index 00000000..6b49205b --- /dev/null +++ b/lib/RoadizRozierBundle/translations/documents/messages.xlf @@ -0,0 +1,60 @@ + + + + + + document.externalUrl + + + + document.limitations + + Documents limitations backoffice section label + + + document.copyrightHolder + + + + document.copyrightValidSince + + + + document.copyrightValidUntil + + + + no_document + + + + unused_documents.help + + + + all.documents + + + + duplicated_documents + + + + document.video_sources + + + + document.audio_sources + + + + document.picture_sources + + + + private_documents + + + + + diff --git a/lib/RoadizRozierBundle/translations/documents/messages.zh.xlf b/lib/RoadizRozierBundle/translations/documents/messages.zh.xlf new file mode 100644 index 00000000..be5899b4 --- /dev/null +++ b/lib/RoadizRozierBundle/translations/documents/messages.zh.xlf @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/lib/RoadizRozierBundle/translations/helps/messages.ar.xlf b/lib/RoadizRozierBundle/translations/helps/messages.ar.xlf new file mode 100644 index 00000000..75f6fcb1 --- /dev/null +++ b/lib/RoadizRozierBundle/translations/helps/messages.ar.xlf @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/lib/RoadizRozierBundle/translations/helps/messages.de.xlf b/lib/RoadizRozierBundle/translations/helps/messages.de.xlf new file mode 100644 index 00000000..00c224f7 --- /dev/null +++ b/lib/RoadizRozierBundle/translations/helps/messages.de.xlf @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/lib/RoadizRozierBundle/translations/helps/messages.en.xlf b/lib/RoadizRozierBundle/translations/helps/messages.en.xlf new file mode 100644 index 00000000..9db65365 --- /dev/null +++ b/lib/RoadizRozierBundle/translations/helps/messages.en.xlf @@ -0,0 +1,61 @@ + + + + + + document.private.help + Move current document file to a non-public server directory. This option disables document display on your website. + + document.imageAverageColor.help + Image average color, useful to create colored placeholder during image loading on your website. + + node.nodeName.help + Node name must be unique and is used to generate your page URL and fetch current node in Themes. It must not contain any special characters. + + tag.tagName.help + Tag name must be unique and is used to generate your page URL and fetch current tag in Themes. It must not contain any special characters. + + tag.locked.help + A locked tag is not renamed after updating its translated title. It prevents deletion too. + + folder.locked.help + A locked folder is not renamed after updating its translated title. It prevents deletion too. + + nodeSource.metaTitle.help + SEO title allows you to write the entire title of your page. For optimal display in search engines, the title should not exceed 55 to 65 characters on average. + + nodeSource.metaKeywords.help + SEO keywords are now optional and are no longer taken into account by search engines. + + nodeSource.metaDescription.help + SEO description is a summary in a few characters of your page. Its purpose is to briefly describe its content. This description must be between 120 and 155 characters long. + + nodeSource.noIndex.help + Prevents robots to index your content and hide them from sitemap. + + nodeSource.urlAlias.help + URL aliases allow you to rewrite the last part of the URL of your page for each translation. They must be different from the node name and unique throughout your site. + + nodeSource.redirections.help + Automatic redirections always redirect visitors from a given request path to the up-to-date current node URL, for this translation. Created redirections will be "permanent" type. + + nodeType.hidingNonReachableNodes.help + This node-type will only hide its non-reachable children nodes + + exclude_this_field_from_fulltext_search_engine + Exclude this field value from full-text search engine. + + source_translation.help + This will copy node contents from this translation. + + translate_offspring.help + This will propagate translation to every node children when they are not translated yet in destination language. + + user.publicName.help + If filled, public name may be displayed to any other website user, you can use it as a pseudonym. + + document.createdAt.help + Allows editing Document creation date and time for sorting purposes. + + + diff --git a/lib/RoadizRozierBundle/translations/helps/messages.es.xlf b/lib/RoadizRozierBundle/translations/helps/messages.es.xlf new file mode 100644 index 00000000..4e8f1c86 --- /dev/null +++ b/lib/RoadizRozierBundle/translations/helps/messages.es.xlf @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/lib/RoadizRozierBundle/translations/helps/messages.fr.xlf b/lib/RoadizRozierBundle/translations/helps/messages.fr.xlf new file mode 100644 index 00000000..976e0bcc --- /dev/null +++ b/lib/RoadizRozierBundle/translations/helps/messages.fr.xlf @@ -0,0 +1,61 @@ + + + + + + document.private.help + Déplace le fichier dans un dossier du serveur non-accessible publiquement. Cette option désactive l’affichage du document dans votre site. + + document.imageAverageColor.help + Couleur moyenne de votre image pour créer des placeholders colorés pendant de le chargement de vos pages. + + node.nodeName.help + Le nom du nœud doit être unique et permet de générer les URL de vos pages et de repérer le nœud depuis vos thèmes. Il ne doit contenir aucun caractère spécial. + + tag.tagName.help + Le nom de l’étiquette doit être unique et permet de générer les URL de vos pages et de repérer l’étiquette depuis vos thèmes. Il ne doit contenir aucun caractère spécial. + + tag.locked.help + Une étiquette verrouillée n’est pas renommée lorsque vous modifiez son titre dans la traduction principale. Cela empêche aussi sa suppression. + + folder.locked.help + Un dossier verrouillé n’est pas renommé lorsque vous modifiez son titre dans la traduction principale. Cela empêche aussi sa suppression. + + nodeSource.metaTitle.help + Le titre de référencement permet de rédiger entièrement le titre de votre page. Pour un affichage optimal dans les moteurs de recherche, le titre ne doit pas dépasser 55 à 65 caractères en moyenne. + + nodeSource.metaKeywords.help + Les mot-clés de référencement sont aujourd’hui facultatifs et ne sont plus pris en compte par les moteurs de recherche. + + nodeSource.metaDescription.help + La description est un résumé de quelques caractères de votre page. Son objectif est de décrire succinctement son contenu. Cette description doit respecter une taille entre 120 et 155 caractères. + + nodeSource.noIndex.help + Indique aux robots de ne pas indexer le contenu et de le cacher du sitemap. + + nodeSource.urlAlias.help + Les alias d’URL permettent de réécrire la dernière partie de l’URL de votre page pour chaque traduction. Ils doivent être différents du nom du nœud et uniques sur tout votre site. + + nodeSource.redirections.help + Les redirections automatiques permettent de rediriger la requête saisie vers l'URL actuel du noeud, et pour cette langue en particulier. Les redirections créées sont toujours du type "permanent". + + nodeType.hidingNonReachableNodes.help + Ce type de nœud cachera seulement les nœuds enfant non-accessibles. + + exclude_this_field_from_fulltext_search_engine + Exclut la valeur de ce champ du moteur de recherche plein-texte. + + source_translation.help + Traduction à partir de laquelle tous les contenus seront dupliqués. + + translate_offspring.help + Effectue la traduction sur tous les nœuds enfants en cascade lorsque ceux-ci ne sont pas traduits dans la langue de destination. + + user.publicName.help + Si rempli, le nom publique peut être affiché librement sur le site à la manière d'un pseudonyme. + + document.createdAt.help + Permet d'éditer la date de création pour modifier le tri des documents. + + + diff --git a/lib/RoadizRozierBundle/translations/helps/messages.id.xlf b/lib/RoadizRozierBundle/translations/helps/messages.id.xlf new file mode 100644 index 00000000..f9d6ef0d --- /dev/null +++ b/lib/RoadizRozierBundle/translations/helps/messages.id.xlf @@ -0,0 +1,43 @@ + + + + + + document.private.help + Pindahkan berkas dokumen saat ini ke direktori server non-publik. Opsi ini menonaktifkan tampilan dokumen di situs web Anda. + + document.imageAverageColor.help + Gambar rata-rata gambar, berguna untuk membuat placeholder berwarna selama pemuatan gambar di situs web Anda. + + node.nodeName.help + Nama node harus unik dan digunakan untuk menghasilkan URL halaman Anda dan mengambil node saat ini di tema. Itu tidak boleh mengandung karakter khusus. + + tag.tagName.help + Nama tag harus unik dan digunakan untuk menghasilkan URL halaman Anda dan mengambil tag saat ini di tema. Itu tidak boleh mengandung karakter khusus. + + tag.locked.help + Tag yang dikunci tidak diganti namanya setelah memperbarui judul yang diterjemahkan. Ini mencegah penghapusan juga. + + nodeSource.metaTitle.help + Judul SEO memungkinkan Anda untuk menulis seluruh judul halaman Anda. Untuk tampilan optimal di mesin pencari, judulnya tidak boleh melebihi 55 hingga 65 karakter rata-rata. + + nodeSource.metaKeywords.help + Kata kunci SEO sekarang opsional dan tidak lagi diperhitungkan oleh mesin pencari. + + nodeSource.metaDescription.help + Deskripsi SEO adalah ringkasan dalam beberapa karakter halaman Anda. Tujuannya adalah untuk menggambarkan kontennya secara singkat. Deskripsi ini harus antara 120 dan 155 karakter. + + nodeSource.urlAlias.help + URL alias memungkinkan Anda untuk menulis ulang bagian terakhir dari URL halaman Anda untuk setiap terjemahan. Mereka harus berbeda dari nama simpul dan unik di seluruh situs Anda. + + nodeSource.redirections.help + Pengalihan otomatis selalu mengarahkan pengunjung dari sebuah jalur permintaan tertentu ke URL node saat ini (up-to-date), untuk terjemahan ini. Pengalihan yang dibuat akan menjadi jenis "permanen". + + nodeType.hidingNonReachableNodes.help + Jenis node ini hanya akan menyembunyikan node anak tersebut yang tidak dapat dijangkau + + exclude_this_field_from_fulltext_search_engine + Kecualikan nilai bidang ini dari mesin-pencari teks-lengkap. + + + diff --git a/lib/RoadizRozierBundle/translations/helps/messages.it.xlf b/lib/RoadizRozierBundle/translations/helps/messages.it.xlf new file mode 100644 index 00000000..363c1f0f --- /dev/null +++ b/lib/RoadizRozierBundle/translations/helps/messages.it.xlf @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/lib/RoadizRozierBundle/translations/helps/messages.ru.xlf b/lib/RoadizRozierBundle/translations/helps/messages.ru.xlf new file mode 100644 index 00000000..f25ae24e --- /dev/null +++ b/lib/RoadizRozierBundle/translations/helps/messages.ru.xlf @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/lib/RoadizRozierBundle/translations/helps/messages.sr.xlf b/lib/RoadizRozierBundle/translations/helps/messages.sr.xlf new file mode 100644 index 00000000..3b94aaf1 --- /dev/null +++ b/lib/RoadizRozierBundle/translations/helps/messages.sr.xlf @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/lib/RoadizRozierBundle/translations/helps/messages.tr.xlf b/lib/RoadizRozierBundle/translations/helps/messages.tr.xlf new file mode 100644 index 00000000..19e77f7e --- /dev/null +++ b/lib/RoadizRozierBundle/translations/helps/messages.tr.xlf @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/lib/RoadizRozierBundle/translations/helps/messages.uk.xlf b/lib/RoadizRozierBundle/translations/helps/messages.uk.xlf new file mode 100644 index 00000000..da76b03f --- /dev/null +++ b/lib/RoadizRozierBundle/translations/helps/messages.uk.xlf @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/lib/RoadizRozierBundle/translations/helps/messages.xlf b/lib/RoadizRozierBundle/translations/helps/messages.xlf new file mode 100644 index 00000000..d1de0b28 --- /dev/null +++ b/lib/RoadizRozierBundle/translations/helps/messages.xlf @@ -0,0 +1,61 @@ + + + + + + document.private.help + + + document.imageAverageColor.help + + + node.nodeName.help + + + tag.tagName.help + + + tag.locked.help + + + folder.locked.help + + + nodeSource.metaTitle.help + + + nodeSource.metaKeywords.help + + + nodeSource.metaDescription.help + + + nodeSource.noIndex.help + + + nodeSource.urlAlias.help + + + nodeSource.redirections.help + + + nodeType.hidingNonReachableNodes.help + + + exclude_this_field_from_fulltext_search_engine + + + source_translation.help + + + translate_offspring.help + + + user.publicName.help + + + document.createdAt.help + + + + diff --git a/lib/RoadizRozierBundle/translations/helps/messages.zh.xlf b/lib/RoadizRozierBundle/translations/helps/messages.zh.xlf new file mode 100644 index 00000000..ad9d67b1 --- /dev/null +++ b/lib/RoadizRozierBundle/translations/helps/messages.zh.xlf @@ -0,0 +1,43 @@ + + + + + + document.private.help + 移动目前的文档到一个非公开的服务器目录。此选项禁用您网站上的文档显示。 + + document.imageAverageColor.help + 图像平均颜色,对于您网站上的图片加载时创建彩色占位符是非常有用的。 + + node.nodeName.help + 节点名必须是唯一的,且被用于生成您的页面网址和在主题中取得目前的节点。它必须不包含任何特殊字符。 + + tag.tagName.help + 标签名必须是唯一的,且被用于生成您的页面网址和在主题中取得目前的标签。它必须不包含任何特殊字符。 + + tag.locked.help + 被锁定的标签不会在更新翻译称谓后被重命名。它也不会被删除。 + + nodeSource.metaTitle.help + SEO 标题能让您写下您页面的全部标题。为了在搜索引擎中完全显示,标题一般不应该超过 55 到 65 个字。 + + nodeSource.metaKeywords.help + SEO 关键词现在是可选的,且不再被搜索引擎重视。 + + nodeSource.metaDescription.help + SEO 描述是您页面的几个字的总结。它的目的是为了简略的描述它的内容。此描述的长度必须介于 120 到 155 个字之间。 + + nodeSource.urlAlias.help + 网址别名能让您为页面的每个翻译重写页面的网址的最后一部分。它们必须不同于节点名且全站唯一。 + + nodeSource.redirections.help + 自动重定向总是将访客从给出的请求路径重定向到此翻译现有节点最新的网址。已创建的重定向会是 “固定的” 形式。 + + nodeType.hidingNonReachableNodes.help + 此节点类型将只会隐藏它不可到达的子节点。 + + exclude_this_field_from_fulltext_search_engine + 除了全文搜索引擎中的此字段值。 + + + diff --git a/lib/RoadizRozierBundle/translations/realms/messages.ar.xlf b/lib/RoadizRozierBundle/translations/realms/messages.ar.xlf new file mode 100644 index 00000000..564ebdfd --- /dev/null +++ b/lib/RoadizRozierBundle/translations/realms/messages.ar.xlf @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/lib/RoadizRozierBundle/translations/realms/messages.de.xlf b/lib/RoadizRozierBundle/translations/realms/messages.de.xlf new file mode 100644 index 00000000..160b17dd --- /dev/null +++ b/lib/RoadizRozierBundle/translations/realms/messages.de.xlf @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/lib/RoadizRozierBundle/translations/realms/messages.en.xlf b/lib/RoadizRozierBundle/translations/realms/messages.en.xlf new file mode 100644 index 00000000..f05bb959 --- /dev/null +++ b/lib/RoadizRozierBundle/translations/realms/messages.en.xlf @@ -0,0 +1,218 @@ + + + + + + realms + Secure realms + + + + add.a.realm + Create a secure realm + + + + delete.realm.%name% + Delete “%name%” realm + + + + are_you_sure.delete.realm + Are you sure you want to delete this secure realm? This will disable every nodes security from this realm. + + + + edit.realm.%name% + %name% + + + + edit.realm + Edit + + + + back_to.realms + Back to realms + + + + realm.type + Authentication type + + + + realm.type.help + Allow choosing between several authentication types: from simple clear password to a more robust and complete authentication scheme using Role or user accounts + + + + realm.plain_password + Clear password + + + + realm.bearer_role + Role-based authentication + + + + realm.bearer_user + User-based authentication + + + + realm.plainPassword + Clear password + + + + realm.plainPassword.help + Clear password will be used against “Clear password” authentication type. It is unique for the whole realm. + + + + realm.serializationGroup + Serialization group + + + + realm.serializationGroup.help + Each realm provides a serialization group which allows to filter out API fields from nodes or any entities. Use this group when you are creating node types. + + + + realm.role + Role + + + + realm.role.help + This role will be checked when using “Role-based authentication” authentication type. You can create a new role on-purpose for this realm. + + + + realm.role.placeholder + -- No role -- + + + + realm.users + Specific user accounts + + + + realm.users.help + Grant access only to specific user accounts. You must select “User-based authentication” authentication type. + + + + manage.realms + Secure realms + + + + edit.node.%name%.realms + Secure realms + + + + realm_nodes + Joined secure realms + + + + join.a.realm + Join a realm + + + + realm_node.realm + Secure realm + + + + realm_node.realm.help + Choose a previously created secure domain to apply to this node and its tree. + + + + realm_node.realm.placeholder + -- No realm selected -- + + + + realm_node.inheritanceType + Inheritance type + + + + realm_node.inheritanceType.help + Define how secure realm will be applied to node hierarchy. Use “Realm root” to start a realm area, or “No propagation” to cancel parent node realm. + + + + realm_node.root + Realm root + Define realm inheritance root node + + + realm_node.auto + Automatic + Auto inheritance from ancestors realms + + + realm_node.none + No propagation + Prevent any inheritance from ancestors realms + + + join.realm + Join secure realm + Button label to submit new relation between a node and a realm + + + node.no_realm + No realm + Message when node is linked to an empty realm + + + leave.realm + Leave realm + + + are_you_sure.leave.realm + Are you sure you want to leave this secure realm? All node children with “Automatic” inheritance type will leave this realm too. + + + node.%node%.joined.%realm% + Node “%node%” joined “%realm%” realm. + + + node.%node%.left.%realm% + Node “%node%” left “%realm%” realm. + + + realm.behaviour + Behaviour on WebResponse + + + realm.behaviour.help + Define realm behaviour on WebResponses: API fields conditional serialization applies regardless of the chosen behavior + + + realm.behaviour_none + No effect + + + realm.behaviour_deny + Reject requests (error 401) + + + realm.behaviour_hide_blocks + Hide blocks + + + + diff --git a/lib/RoadizRozierBundle/translations/realms/messages.es.xlf b/lib/RoadizRozierBundle/translations/realms/messages.es.xlf new file mode 100644 index 00000000..43991b81 --- /dev/null +++ b/lib/RoadizRozierBundle/translations/realms/messages.es.xlf @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/lib/RoadizRozierBundle/translations/realms/messages.fr.xlf b/lib/RoadizRozierBundle/translations/realms/messages.fr.xlf new file mode 100644 index 00000000..27681a0c --- /dev/null +++ b/lib/RoadizRozierBundle/translations/realms/messages.fr.xlf @@ -0,0 +1,218 @@ + + + + + + realms + Domaines sécurisés + + + + add.a.realm + Créer un domaine sécurisé + + + + delete.realm.%name% + Supprimer le domaine “%name%” + + + + are_you_sure.delete.realm + Êtes-vous sûr.e de vouloir supprimer ce domaine sécurisé ? Cela retirera la sécurité sur tous les nœuds qui font partie de ce domaine. + + + + edit.realm.%name% + %name% + + + + edit.realm + Éditer + + + + back_to.realms + Retour aux domaines + + + + realm.type + Type d'authentification + + + + realm.type.help + Permet de choisir entre plusieurs types d'authentification : du simple mot de passe envoyé en clair à une authentification complète sécurisée par Rôle ou par compte Utilisateur. + + + + realm.plain_password + Mot de passe en clair + + + + realm.bearer_role + Utilisateur authentifié avec rôle + + + + realm.bearer_user + Utilisateur spécifique authentifié + + + + realm.plainPassword + Mot de passe + + + + realm.plainPassword.help + Le mot de passe en clair ne sera utilisé qu'avec le type d'authentification « Mot de passe en clair ». Il est unique à tout le domaine. + + + + realm.serializationGroup + Groupe de sérialisation + + + + realm.serializationGroup.help + Chaque domaine possède un groupe de sérialisation permettant de conditionner l'affichage des champs dans l'API. Utilisez ce groupe lors de la création des types de nœuds. + + + + realm.role + Rôle + + + + realm.role.help + Le rôle sera vérifié seulement avec le type d'authentification « Utilisateur authentifié avec rôle ». Vous pouvez créer un rôle sur mesure pour votre domaine. + + + + realm.role.placeholder + -- Aucun rôle -- + + + + realm.users + Utilisateurs spécifiques + + + + realm.users.help + Permet de donner accès au domaine pour certains utilisateurs seulement. À utiliser avec le type d'authentification « Utilisateur spécifique authentifié ». + + + + manage.realms + Domaines sécurisés + + + + edit.node.%name%.realms + Domaines sécurisés + + + + realm_nodes + Domaines sécurisés rattachés + + + + join.a.realm + Rejoindre un domaine + + + + realm_node.realm + Domaine sécurisé + + + + realm_node.realm.help + Choisissez un domaine sécurisé au préalablement créé pour l'appliquer à ce nœud et à son arborescence. + + + + realm_node.realm.placeholder + -- Aucun domaine sécurisé -- + + + + realm_node.inheritanceType + Type de propagation + + + + realm_node.inheritanceType.help + Définit la manière dont le domaine sécurise sera propagé aux nœuds enfants. Utilisez le mode « Racine » pour débuter une zone, ou « Aucune propagation » pour annuler le domaine d'un nœud parent. + + + + realm_node.root + Racine du domaine + Define realm inheritance root node + + + realm_node.auto + Automatique + Auto inheritance from ancestors realms + + + realm_node.none + Aucune propagation + Prevent any inheritance from ancestors realms + + + join.realm + Rejoindre le domaine sécurisé + Button label to submit new relation between a node and a realm + + + node.no_realm + Aucun domaine + Message when node is linked to an empty realm + + + leave.realm + Quitter le domaine + + + are_you_sure.leave.realm + Êtes-vous sûr.e de vouloir quitter ce domaine sécurisé ? La suppression sera propagée à tous les nœuds enfants ayant le mode de propagation automatique. + + + node.%node%.joined.%realm% + Le nœud “%node%” a rejoint le domaine “%realm%” + + + node.%node%.left.%realm% + Le nœud “%node%” a quitté le domaine “%realm%” + + + realm.behaviour + Comportement sur la WebResponse + + + realm.behaviour.help + Définit le comportement du domaine sécurisé sur la WebResponse : la sérialisation conditionnelle des champs s'applique quel que soit le comportement choisi. + + + realm.behaviour_none + Aucune incidence + + + realm.behaviour_deny + Rejeter la requête (erreur 401) + + + realm.behaviour_hide_blocks + Cacher les blocs + + + + diff --git a/lib/RoadizRozierBundle/translations/realms/messages.id.xlf b/lib/RoadizRozierBundle/translations/realms/messages.id.xlf new file mode 100644 index 00000000..675a8384 --- /dev/null +++ b/lib/RoadizRozierBundle/translations/realms/messages.id.xlf @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/lib/RoadizRozierBundle/translations/realms/messages.it.xlf b/lib/RoadizRozierBundle/translations/realms/messages.it.xlf new file mode 100644 index 00000000..77929b11 --- /dev/null +++ b/lib/RoadizRozierBundle/translations/realms/messages.it.xlf @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/lib/RoadizRozierBundle/translations/realms/messages.ru.xlf b/lib/RoadizRozierBundle/translations/realms/messages.ru.xlf new file mode 100644 index 00000000..2eb42f08 --- /dev/null +++ b/lib/RoadizRozierBundle/translations/realms/messages.ru.xlf @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/lib/RoadizRozierBundle/translations/realms/messages.sr.xlf b/lib/RoadizRozierBundle/translations/realms/messages.sr.xlf new file mode 100644 index 00000000..8313cd3b --- /dev/null +++ b/lib/RoadizRozierBundle/translations/realms/messages.sr.xlf @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/lib/RoadizRozierBundle/translations/realms/messages.tr.xlf b/lib/RoadizRozierBundle/translations/realms/messages.tr.xlf new file mode 100644 index 00000000..3edc0aba --- /dev/null +++ b/lib/RoadizRozierBundle/translations/realms/messages.tr.xlf @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/lib/RoadizRozierBundle/translations/realms/messages.uk.xlf b/lib/RoadizRozierBundle/translations/realms/messages.uk.xlf new file mode 100644 index 00000000..6e1d5ed5 --- /dev/null +++ b/lib/RoadizRozierBundle/translations/realms/messages.uk.xlf @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/lib/RoadizRozierBundle/translations/realms/messages.xlf b/lib/RoadizRozierBundle/translations/realms/messages.xlf new file mode 100644 index 00000000..378bc674 --- /dev/null +++ b/lib/RoadizRozierBundle/translations/realms/messages.xlf @@ -0,0 +1,219 @@ + + + + + + realms + + + + + add.a.realm + + + + + delete.realm.%name% + + + + + are_you_sure.delete.realm + + + + + edit.realm.%name% + + + + + edit.realm + + + + + back_to.realms + + + + + realm.type + + + + + realm.type.help + + + + + realm.plain_password + + + + + realm.bearer_role + + + + + realm.bearer_user + + + + + realm.plainPassword + + + + + realm.plainPassword.help + + + + + realm.serializationGroup + + + + + realm.serializationGroup.help + + + + + realm.role + + + + + realm.role.help + + + + + realm.role.placeholder + + + + + realm.users + + + + + realm.users.help + + + + + manage.realms + + + + + edit.node.%name%.realms + + + + + realm_nodes + + + + + join.a.realm + + + + + realm_node.realm + + + + + realm_node.realm.help + + + + + realm_node.realm.placeholder + + + + + realm_node.inheritanceType + + + + + realm_node.inheritanceType.help + + + + + realm_node.root + + Define realm inheritance root node + + + realm_node.auto + + Auto inheritance from ancestors realms + + + realm_node.none + + Prevent any inheritance from ancestors realms + + + join.realm + + Button label to submit new relation between a node and a realm + + + node.no_realm + + Message when node is linked to an empty realm + + + leave.realm + + + + are_you_sure.leave.realm + + + + node.%node%.joined.%realm% + + + + node.%node%.left.%realm% + + + + + realm.behaviour + + + + realm.behaviour.help + + + + realm.behaviour_none + + + + realm.behaviour_deny + + + + realm.behaviour_hide_blocks + + + + + diff --git a/lib/RoadizRozierBundle/translations/realms/messages.zh.xlf b/lib/RoadizRozierBundle/translations/realms/messages.zh.xlf new file mode 100644 index 00000000..0070009d --- /dev/null +++ b/lib/RoadizRozierBundle/translations/realms/messages.zh.xlf @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/lib/RoadizRozierBundle/translations/redirections/messages.ar.xlf b/lib/RoadizRozierBundle/translations/redirections/messages.ar.xlf new file mode 100644 index 00000000..4c0dcac7 --- /dev/null +++ b/lib/RoadizRozierBundle/translations/redirections/messages.ar.xlf @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/lib/RoadizRozierBundle/translations/redirections/messages.de.xlf b/lib/RoadizRozierBundle/translations/redirections/messages.de.xlf new file mode 100644 index 00000000..209a0667 --- /dev/null +++ b/lib/RoadizRozierBundle/translations/redirections/messages.de.xlf @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/lib/RoadizRozierBundle/translations/redirections/messages.en.xlf b/lib/RoadizRozierBundle/translations/redirections/messages.en.xlf new file mode 100644 index 00000000..724fd094 --- /dev/null +++ b/lib/RoadizRozierBundle/translations/redirections/messages.en.xlf @@ -0,0 +1,11 @@ + + + + + + no_redirection_found + No redirection found + + + + diff --git a/lib/RoadizRozierBundle/translations/redirections/messages.es.xlf b/lib/RoadizRozierBundle/translations/redirections/messages.es.xlf new file mode 100644 index 00000000..9258a67c --- /dev/null +++ b/lib/RoadizRozierBundle/translations/redirections/messages.es.xlf @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/lib/RoadizRozierBundle/translations/redirections/messages.fr.xlf b/lib/RoadizRozierBundle/translations/redirections/messages.fr.xlf new file mode 100644 index 00000000..2a516823 --- /dev/null +++ b/lib/RoadizRozierBundle/translations/redirections/messages.fr.xlf @@ -0,0 +1,11 @@ + + + + + + no_redirection_found + Aucune redirection trouvée + + + + diff --git a/lib/RoadizRozierBundle/translations/redirections/messages.id.xlf b/lib/RoadizRozierBundle/translations/redirections/messages.id.xlf new file mode 100644 index 00000000..e65ee5b6 --- /dev/null +++ b/lib/RoadizRozierBundle/translations/redirections/messages.id.xlf @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/lib/RoadizRozierBundle/translations/redirections/messages.it.xlf b/lib/RoadizRozierBundle/translations/redirections/messages.it.xlf new file mode 100644 index 00000000..33c3dbf8 --- /dev/null +++ b/lib/RoadizRozierBundle/translations/redirections/messages.it.xlf @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/lib/RoadizRozierBundle/translations/redirections/messages.ru.xlf b/lib/RoadizRozierBundle/translations/redirections/messages.ru.xlf new file mode 100644 index 00000000..567bd4f1 --- /dev/null +++ b/lib/RoadizRozierBundle/translations/redirections/messages.ru.xlf @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/lib/RoadizRozierBundle/translations/redirections/messages.sr.xlf b/lib/RoadizRozierBundle/translations/redirections/messages.sr.xlf new file mode 100644 index 00000000..bb6c4398 --- /dev/null +++ b/lib/RoadizRozierBundle/translations/redirections/messages.sr.xlf @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/lib/RoadizRozierBundle/translations/redirections/messages.tr.xlf b/lib/RoadizRozierBundle/translations/redirections/messages.tr.xlf new file mode 100644 index 00000000..aee712c0 --- /dev/null +++ b/lib/RoadizRozierBundle/translations/redirections/messages.tr.xlf @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/lib/RoadizRozierBundle/translations/redirections/messages.uk.xlf b/lib/RoadizRozierBundle/translations/redirections/messages.uk.xlf new file mode 100644 index 00000000..2962e198 --- /dev/null +++ b/lib/RoadizRozierBundle/translations/redirections/messages.uk.xlf @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/lib/RoadizRozierBundle/translations/redirections/messages.xlf b/lib/RoadizRozierBundle/translations/redirections/messages.xlf new file mode 100644 index 00000000..c20384a4 --- /dev/null +++ b/lib/RoadizRozierBundle/translations/redirections/messages.xlf @@ -0,0 +1,11 @@ + + + + + + no_redirection_found + + + + + diff --git a/lib/RoadizRozierBundle/translations/redirections/messages.zh.xlf b/lib/RoadizRozierBundle/translations/redirections/messages.zh.xlf new file mode 100644 index 00000000..b5d76e39 --- /dev/null +++ b/lib/RoadizRozierBundle/translations/redirections/messages.zh.xlf @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/lib/RoadizRozierBundle/translations/settings/messages.ar.xlf b/lib/RoadizRozierBundle/translations/settings/messages.ar.xlf new file mode 100644 index 00000000..1aafd272 --- /dev/null +++ b/lib/RoadizRozierBundle/translations/settings/messages.ar.xlf @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/lib/RoadizRozierBundle/translations/settings/messages.de.xlf b/lib/RoadizRozierBundle/translations/settings/messages.de.xlf new file mode 100644 index 00000000..d9537549 --- /dev/null +++ b/lib/RoadizRozierBundle/translations/settings/messages.de.xlf @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/lib/RoadizRozierBundle/translations/settings/messages.en.xlf b/lib/RoadizRozierBundle/translations/settings/messages.en.xlf new file mode 100644 index 00000000..4a442597 --- /dev/null +++ b/lib/RoadizRozierBundle/translations/settings/messages.en.xlf @@ -0,0 +1,230 @@ + + + + + + Force displaying translation locale in every node’ paths. This should be ON if you redirect users based on their language on homepage. + Force displaying translation locale in every node’ paths. This should be ON if you redirect users based on their language on homepage. + + + Default sender email, used as origin for every system email sent. This email **must be allowed by your SMTP server.** + Default sender email, used as origin for every system email sent. This email **must be allowed by your SMTP server.** + + + Default maps tiles layout when using *Leaflet*. + Default maps tiles layout when using *Leaflet*. + + + Replace random *Splashbase* login images with your own. + Replace random *Splashbase* login images with your own. + + + Switch maintenance mode. Only login page will be available for public requests. + Switch maintenance mode. Only login page will be available for public requests. + + + Default maps marker location. + Default maps marker location. + + + Default maps tiles layout when using *Google Maps*. + Default maps tiles layout when using *Google Maps*. + + + Maximum versions count showed in actions menu. + Maximum versions count showed in actions menu. + + + hide_roadiz_version.help + Hide Roadiz version and name from any response headers and theme variables. + Setting + + + force_locale_with_urlaliases + Display locale when using url-aliases + Setting name + + + force_locale_with_urlaliases.help + This will force displaying translation locale in generated node-source paths even if there is an url-alias in it. + Setting description + + + use_native_json + Use native JSON field type + Setting name + + + Use MySQL 5.7+ JSON field type. + Use MySQL JSON field type available since version 5.7. + Setting description + + + oauth_client_id + OAuth2 Client ID + Setting name + + + oauth_client_id.help + Client ID to paste from your OpenID identity provider (OAuth2) + Setting description + + + oauth_client_secret + OAuth2 Client secret + Setting name + + + oauth_client_secret.help + Secret to paste from your OpenID identity provider (OAuth2). + Setting description + + + openid_hd + Restrict to domain name + Setting name + + + openid_hd.help + Allows only domain name emails to authenticate against OpenID provider. + Setting description + + + openid_button_label + Connect with OpenId button label + Setting name + + + openid_button_label.help + Customize login page OpenID button label. + Setting description + + + openid_discovery + Autodiscovery OpenID URL + Setting name + + + openid_discovery.help + Standard OpenID autodiscovery URL, required to enable OpenId login in Roadiz CMS. + Setting description + + + openid_default_roles + Attributed user roles for OpenID domain + Setting name + + + openid_default_roles.help + List user roles, comma separated, to be attributed to every authenticated user with OpenID. + Setting description + + + openid_scopes + Open ID / OAuth2 scopes + Setting name + + + openid_scopes.help + Restrict your Open ID / OAuth2 scopes. Write scopes separated with spaces. If you leave it empty, OpenID connection will try to request all available scopes from Discovery document. + Setting description + + + custom_preview_scheme + Custom preview scheme + Setting name + + + custom_preview_scheme.help + Replace "?_preview=1" query string to preview website content with a dedicated domain name. It can be useful when using *headless* Roadiz version. + Setting description + + + custom_public_scheme + Custom public scheme + Setting name + + + custom_public_scheme.help + Replace your public website URL with a dedicated domain name. It can be useful when using *headless* Roadiz version. + Setting description + + + use_typed_node_names + Use typed node names + Setting name + + + use_typed_node_names.help + Once enabled, this option will suffix each name for unreachable nodes (blocks) with their node-type to avoid name conflicts with reachable nodes (pages). + Setting description + + + openid_username_claim + Open ID username claim + Setting name + + + openid_username_claim.help + If empty, Roadiz will use standard “email” claim as user-name for every Open ID accounts. + Setting description + + + + APIs + APIs + Setting group name + + + Development + Development + Setting group name + + + Emailings + Emailings + Setting group name + + + OpenId + OpenID + Setting group name + + + Site informations + Site information + Setting group name + + + Site information + Site information + Setting group name + + + Social networks + Social networks + Setting group name + + + dashboard_iframe + Custom iframe for dashboard + Setting name + + + dashboard_iframe.help + Displays a custom iframe on your back-office dashboard, for example, plausible.io website analytics embed. + Setting help name + + + unsplash_client_id + Unsplash API : client ID + Setting name + + + unsplash_client_id.help + Unsplash API Client ID, if you fill in a valid ID, Roadiz will be able to upload random images on the login page and in the Documents management. + Setting help name + + + + diff --git a/lib/RoadizRozierBundle/translations/settings/messages.es.xlf b/lib/RoadizRozierBundle/translations/settings/messages.es.xlf new file mode 100644 index 00000000..57c2395f --- /dev/null +++ b/lib/RoadizRozierBundle/translations/settings/messages.es.xlf @@ -0,0 +1,18 @@ + + + + + + + Development + Desarrollo + Setting group name + + + Social networks + Redes sociales + Setting group name + + + + diff --git a/lib/RoadizRozierBundle/translations/settings/messages.fr.xlf b/lib/RoadizRozierBundle/translations/settings/messages.fr.xlf new file mode 100644 index 00000000..393fb241 --- /dev/null +++ b/lib/RoadizRozierBundle/translations/settings/messages.fr.xlf @@ -0,0 +1,230 @@ + + + + + + Force displaying translation locale in every node’ paths. This should be ON if you redirect users based on their language on homepage. + Forcer l'affichage de la locale de traduction dans les chemins de chaque noeud, même pour la langue par défaut. Cela devrait être activé si vous redirigez les utilisateurs en fonction de leur langue sur la page d'accueil. + + + Default sender email, used as origin for every system email sent. This email **must be allowed by your SMTP server.** + Email d'expéditeur par défaut, utilisé comme Origine pour chaque email système envoyé. Cet email **doit être autorisé par votre serveur SMTP.** + + + Default maps tiles layout when using *Leaflet*. + Habillage par défaut des cartes lorsque vous utilisez *Leaflet*. + + + Replace random *Splashbase* login images with your own. + Remplace les images aléatoires *Splashbase* avec votre propre image d’arrière-plan. + + + Switch maintenance mode. Only login page will be available for public requests. + Active/désactive le mode maintenance. Seulement les pages d'identification seront disponibles pour les requêtes publiques. + + + Default maps marker location. + Emplacement par défaut sur les cartes. + + + Default maps tiles layout when using *Google Maps*. + Habillage par défaut des cartes lorsque vous utilisez *Google Maps*. + + + Maximum versions count showed in actions menu. + Nombre maximum de versions affichées dans le menu des actions. + + + hide_roadiz_version.help + Cache le numéro et le nom du CMS des en-têtes HTTP et des variables du thème. + Setting + + + force_locale_with_urlaliases + Afficher la locale avec les alias d’URL + Setting name + + + force_locale_with_urlaliases.help + La locale sera toujours affichée dans le chemin généré pour une source de nœud même si elle possède un alias d’URL dans la langue demandée. + Setting description + + + use_native_json + Utiliser le format JSON natif + Setting name + + + Use MySQL 5.7+ JSON field type. + Utiliser le type de champ JSON MySQL disponible à partir de la version 5.7. + Setting description + + + oauth_client_id + OAuth2 Client ID + Setting name + + + oauth_client_id.help + Identifiant à récupérer depuis votre fournisseur d’identité OpenID (OAuth2). + Setting description + + + oauth_client_secret + OAuth2 Client secret + Setting name + + + oauth_client_secret.help + Secret à récupérer depuis votre fournisseur d’identité OpenID (OAuth2). + Setting description + + + openid_hd + Restreindre au nom de domaine + Setting name + + + openid_hd.help + Autorise seulement les emails du nom de domaine à s’authentifier avec OpenID. + Setting description + + + openid_button_label + Label du bouton de connexion OpenID + Setting name + + + openid_button_label.help + Personnaliser le label du bouton sur la page de connexion au back-office. + Setting description + + + openid_discovery + URL d’auto-découverte OpenID + Setting name + + + openid_discovery.help + Lien standard d’auto-découverte OpenID, nécessaire à l’activation de la fonctionnalité OpenID dans Roadiz. + Setting description + + + openid_default_roles + Rôles attribués automatiquement au domaine OpenID + Setting name + + + openid_default_roles.help + Lister les rôles, séparés par virgule, à attribuer automatiquement aux utilisateurs authentifiés via OpenID. + Setting description + + + openid_scopes + Scopes Open ID / OAuth2 + Setting name + + + openid_scopes.help + Réduit la liste des scopes demandés. Lister les scopes séparés par un espace. Si la liste est laissée vide, OpenID tentera de se connecter avec tous les scopes disponibles depuis le document d’auto-découverte. + Setting description + + + custom_preview_scheme + Système d'URL de prévisualisation personnalisé + Setting name + + + custom_preview_scheme.help + Remplace la query-string "?preview=1" des URL de prévisualisation par un nom de domaine personnalisé. Cela peut être utile avec la version *Headless* de Roadiz. + Setting description + + + custom_public_scheme + Système d'URL public personnalisé + Setting name + + + custom_public_scheme.help + Remplace les URL publics de votre site web avec un nom de domaine personnalisé. Cela peut être utile avec la version *Headless* de Roadiz. + Setting description + + + use_typed_node_names + Utiliser des noms de nœuds typés + Setting name + + + use_typed_node_names.help + Une fois activée, cette option suffixera chaque nom des nœuds non-atteignables (blocs) avec leur type pour éviter les conflits de nom avec les nœud atteignables (pages). + Setting description + + + openid_username_claim + Claim du nom d’utilisateur Open ID + Setting name + + + openid_username_claim.help + Si vide, Roadiz utilisera le claim “email” comme nom d’utilisateur Open ID + Setting description + + + + APIs + API + Setting group name + + + Development + Développement + Setting group name + + + Emailings + Envoi d'e-mails + Setting group name + + + OpenId + OpenID + Setting group name + + + Site informations + Informations du site + Setting group name + + + Site information + Informations du site + Setting group name + + + Social networks + Réseaux sociaux + Setting group name + + + dashboard_iframe + Iframe personnalisée pour le Dashboard + Setting name + + + dashboard_iframe.help + Permet d'afficher une iframe sur le dashboard du back-office. Par exemple, les statistiques de visites à l'aide de plausible.io + Setting help name + + + unsplash_client_id + Unsplash API : client ID + Setting name + + + unsplash_client_id.help + Client ID de l'API Unsplash, si vous remplissez un client valide, Roadiz sera en mesure de télécharger des images aléatoires sur la page de login et dans la gestion des Documents. + Setting help name + + + + diff --git a/lib/RoadizRozierBundle/translations/settings/messages.id.xlf b/lib/RoadizRozierBundle/translations/settings/messages.id.xlf new file mode 100644 index 00000000..e99d0d99 --- /dev/null +++ b/lib/RoadizRozierBundle/translations/settings/messages.id.xlf @@ -0,0 +1,145 @@ + + + + + + Force displaying translation locale in every node’ paths. This should be ON if you redirect users based on their language on homepage. + Paksa tampilkan lokal (bahasa) terjemahan di jalur/path setiap node. Ini harus AKTIF jika Anda mengarahkan pengguna berdasarkan bahasa mereka di halaman Beranda. + + + Default sender email, used as origin for every system email sent. This email **must be allowed by your SMTP server.** + Email pengirim bawaan, digunakan sebagai asal untuk setiap sistem email yang dikirim. Email ini **harus diizinkan oleh server SMTP Anda.** + + + Default maps tiles layout when using *Leaflet*. + Tata letak peta bawaan saat menggunakan *Leaflet*. + + + Replace random *Splashbase* login images with your own. + Ganti gambar login masuk acak *Splashbase* dengan milik Anda sendiri. + + + Switch maintenance mode. Only login page will be available for public requests. + Beralih mode pemeliharaan. Hanya halaman login masuk yang akan tersedia untuk permintaan publik. + + + Default maps marker location. + Lokasi penanda peta bawaan. + + + Default maps tiles layout when using *Google Maps*. + Tata letak peta bawaan saat menggunakan *Google Maps*. + + + Maximum versions count showed in actions menu. + Jumlah versi maksimum ditunjukkan pada menu tindakan. + + + hide_roadiz_version.help + Sembunyikan versi Roadiz dan nama dari setiap header respons dan variabel tema. + Setting + + + force_locale_with_urlaliases + Tampilkan lokal (bahasa) saat menggunakan alias URL + Setting name + + + force_locale_with_urlaliases.help + Ini akan memaksa menampilkan penerjemahan lokal di jalur sumber node yang dihasilkan bahkan jika ada URL-alias di dalamnya. + Setting description + + + use_native_json + Gunakan jenis bidang JSON asli + Setting name + + + Use MySQL 5.7+ JSON field type. + Gunakan jenis bidang MySQL JSON versi 5.7+. + Setting description + + + oauth_client_id + ID Klien OAuth2 + Setting name + + + oauth_client_id.help + ID klien untuk menempel dari penyedia identitas OpenID Anda (OAUTH2) + Setting description + + + oauth_client_secret + Kunci rahasia Klien OAuth2 + Setting name + + + oauth_client_secret.help + Kunci rahasia untuk menempel dari penyedia identitas OpenID Anda (OAUTH2). + Setting description + + + openid_hd + Batasi ke nama domain + Setting name + + + openid_hd.help + Hanya izinkan email nama domain untuk mengautentikasi terhadap Penyedia OpenID. + Setting description + + + openid_button_label + Terhubung dengan label tombol OpenID + Setting name + + + openid_button_label.help + Kustomisasi label tombol OpenID Halaman Login. + Setting description + + + openid_discovery + Penemuan Otomatis URL OpenID + Setting name + + + openid_discovery.help + Penemuan Otomatis URL OpenID Standar, diperlukan untuk mengaktifkan OpenID Login di Roadiz CMS. + Setting description + + + openid_default_roles + Peran pengguna yang diberikan atribut untuk OpenID Domain + Setting name + + + openid_default_roles.help + Daftar peran pengguna, yang dipisahkan koma, untuk diberikan atribut pada setiap pengguna yang diautentikasi dengan OpenID. + Setting description + + + openid_scopes + Buka ID / cakupan OAuth2 + Setting name + + + openid_scopes.help + Batasi cakupan ID / OAUTH2 Anda. Tulis cakupan yang dipisahkan dengan spasi. Jika Anda membiarkannya kosong, koneksi OpenID akan mencoba untuk meminta semua cakupan yang tersedia dari dokumen Penemuan. + Setting description + + + openid_username_claim + Buka ID Nama Pengguna + Setting name + + + openid_username_claim.help + Jika kosong, Roadiz akan menggunakan "email" standar sebagai nama pengguna untuk setiap akun OpenID. + Setting description + + + + + diff --git a/lib/RoadizRozierBundle/translations/settings/messages.it.xlf b/lib/RoadizRozierBundle/translations/settings/messages.it.xlf new file mode 100644 index 00000000..1f443fac --- /dev/null +++ b/lib/RoadizRozierBundle/translations/settings/messages.it.xlf @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/lib/RoadizRozierBundle/translations/settings/messages.ru.xlf b/lib/RoadizRozierBundle/translations/settings/messages.ru.xlf new file mode 100644 index 00000000..b2de95c5 --- /dev/null +++ b/lib/RoadizRozierBundle/translations/settings/messages.ru.xlf @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/lib/RoadizRozierBundle/translations/settings/messages.sr.xlf b/lib/RoadizRozierBundle/translations/settings/messages.sr.xlf new file mode 100644 index 00000000..f53be8c5 --- /dev/null +++ b/lib/RoadizRozierBundle/translations/settings/messages.sr.xlf @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/lib/RoadizRozierBundle/translations/settings/messages.tr.xlf b/lib/RoadizRozierBundle/translations/settings/messages.tr.xlf new file mode 100644 index 00000000..3e8e2e36 --- /dev/null +++ b/lib/RoadizRozierBundle/translations/settings/messages.tr.xlf @@ -0,0 +1,13 @@ + + + + + + Default sender email, used as origin for every system email sent. This email **must be allowed by your SMTP server.** + +Gönderilen her sistem e-postası için kaynak olarak kullanılan varsayılan gönderen e-postası. Bu e-postaya ** SMTP sunucunuz tarafından izin verilmelidir. ** + + + + + diff --git a/lib/RoadizRozierBundle/translations/settings/messages.uk.xlf b/lib/RoadizRozierBundle/translations/settings/messages.uk.xlf new file mode 100644 index 00000000..5eefb79f --- /dev/null +++ b/lib/RoadizRozierBundle/translations/settings/messages.uk.xlf @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/lib/RoadizRozierBundle/translations/settings/messages.xlf b/lib/RoadizRozierBundle/translations/settings/messages.xlf new file mode 100644 index 00000000..4963ce2c --- /dev/null +++ b/lib/RoadizRozierBundle/translations/settings/messages.xlf @@ -0,0 +1,235 @@ + + + + + + Force displaying translation locale in every node’ paths. This should be ON if you redirect users based on their language on homepage. + + + + Default sender email, used as origin for every system email sent. This email **must be allowed by your SMTP server.** + + + + Default maps tiles layout when using *Leaflet*. + + + + Replace random *Splashbase* login images with your own. + + + + Switch maintenance mode. Only login page will be available for public requests. + + + + Default maps marker location. + + + + Default maps tiles layout when using *Google Maps*. + + + + Maximum versions count showed in actions menu. + + + + hide_roadiz_version.help + + Setting + + + force_locale_with_urlaliases + + Setting name + + + force_locale_with_urlaliases.help + + Setting description + + + use_native_json + + Setting name + + + Use MySQL 5.7+ JSON field type. + + Setting description + + + oauth_client_id + + Setting name + + + oauth_client_id.help + + Setting description + + + oauth_client_secret + + Setting name + + + oauth_client_secret.help + + Setting description + + + openid_hd + + Setting name + + + openid_hd.help + + Setting description + + + openid_button_label + + Setting name + + + openid_button_label.help + + Setting description + + + openid_discovery + + Setting name + + + openid_discovery.help + + Setting description + + + openid_default_roles + + Setting name + + + openid_default_roles.help + + Setting description + + + + openid_scopes + + Setting name + + + openid_scopes.help + + Setting description + + + + custom_preview_scheme + + Setting name + + + custom_preview_scheme.help + + Setting description + + + + custom_public_scheme + + Setting name + + + custom_public_scheme.help + + Setting description + + + + use_typed_node_names + + Setting name + + + use_typed_node_names.help + + Setting description + + + + openid_username_claim + + Setting name + + + openid_username_claim.help + + Setting description + + + + APIs + + Setting group name + + + Development + + Setting group name + + + Emailings + + Setting group name + + + OpenId + + Setting group name + + + Site informations + + Setting group name + + + Site information + + Setting group name + + + Social networks + + Setting group name + + + dashboard_iframe + + Setting name + + + dashboard_iframe.help + + Setting help name + + + unsplash_client_id + + Setting name + + + unsplash_client_id.help + + Setting help name + + + + diff --git a/lib/RoadizRozierBundle/translations/settings/messages.zh.xlf b/lib/RoadizRozierBundle/translations/settings/messages.zh.xlf new file mode 100644 index 00000000..9080b73d --- /dev/null +++ b/lib/RoadizRozierBundle/translations/settings/messages.zh.xlf @@ -0,0 +1,165 @@ + + + + + + Force displaying translation locale in every node’ paths. This should be ON if you redirect users based on their language on homepage. + 强制在每个节点的路径中显示翻译本地化。如果您在首页基于他们的语言重定向用户,这应该要开启。 + + + Default sender email, used as origin for every system email sent. This email **must be allowed by your SMTP server.** + 默认电子邮件发送者,作为所有系统邮件的发送来源。此电子邮件 **必须被您的 SMTP 服务器允许**。 + + + Default maps tiles layout when using *Leaflet*. + 当使用 *Leaflet* 时的默认地图图层布局。 + + + Replace random *Splashbase* login images with your own. + 用您自己的图片替换 *Splashbase* 的随机登录图片。 + + + Switch maintenance mode. Only login page will be available for public requests. + 切换维护模式。只有登录页面会回应公开请求。 + + + Default maps marker location. + 默认地图标记位置。 + + + Default maps tiles layout when using *Google Maps*. + 当使用 *Google Maps* 时的默认地图图层布局。 + + + Maximum versions count showed in actions menu. + 操作菜单中显示的最大版本数计数。 + + + hide_roadiz_version.help + 在回应头及主题变量中隐藏 Roadiz 版本和名称。 + Setting + + + force_locale_with_urlaliases + 使用网址别名时显示区域代码 + Setting name + + + force_locale_with_urlaliases.help + 这将在节点源路径中强制显示区域代码,即使网址别名已经存在。 + Setting description + + + use_native_json + 使用原生 JSON 字段类型 + Setting name + + + Use MySQL 5.7+ JSON field type. + 使用 MySQL 5.7+ JSON 字段类型 + Setting description + + + oauth_client_id + OAuth2 Client ID + Setting name + + + oauth_client_id.help + 粘贴从您的 OpenID 身份验证提供者(OAuth2)取得的 Client ID + Setting description + + + oauth_client_secret + OAuth2 Client secret + Setting name + + + oauth_client_secret.help + 粘贴从您的 OpenID 身份验证提供者(OAuth2)取得的密钥 + Setting description + + + openid_hd + 限制为域名 + Setting name + + + openid_button_label + 与 OpenID 按钮标签连接 + Setting name + + + openid_discovery + 自动发现 OpenID URL + Setting name + + + custom_preview_scheme + 自定义预览格式 + Setting name + + + custom_public_scheme + 自定义公开格式 + Setting name + + + use_typed_node_names + 使用已输入的节点名称 + Setting name + + + openid_username_claim + 声明 OpenID 用户名 + Setting name + + + + APIs + API + Setting group name + + + Development + 开发 + Setting group name + + + Emailings + 电子邮件 + Setting group name + + + OpenId + OpenID + Setting group name + + + Site informations + 站点信息 + Setting group name + + + Site information + 站点信息 + Setting group name + + + Social networks + 社交网络 + Setting group name + + + dashboard_iframe + 为控制面板自定义 iframe + Setting name + + + unsplash_client_id + Unsplash API : client ID + Setting name + + + + diff --git a/lib/RoadizRozierBundle/translations/users/messages.ar.xlf b/lib/RoadizRozierBundle/translations/users/messages.ar.xlf new file mode 100644 index 00000000..b5f274b8 --- /dev/null +++ b/lib/RoadizRozierBundle/translations/users/messages.ar.xlf @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/lib/RoadizRozierBundle/translations/users/messages.de.xlf b/lib/RoadizRozierBundle/translations/users/messages.de.xlf new file mode 100644 index 00000000..aafc1d42 --- /dev/null +++ b/lib/RoadizRozierBundle/translations/users/messages.de.xlf @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/lib/RoadizRozierBundle/translations/users/messages.en.xlf b/lib/RoadizRozierBundle/translations/users/messages.en.xlf new file mode 100644 index 00000000..767ba683 --- /dev/null +++ b/lib/RoadizRozierBundle/translations/users/messages.en.xlf @@ -0,0 +1,19 @@ + + + + + + user.lastLogin + Last login + + + user.neverLoggedIn + Never + + + publicName + Public name + + + + diff --git a/lib/RoadizRozierBundle/translations/users/messages.es.xlf b/lib/RoadizRozierBundle/translations/users/messages.es.xlf new file mode 100644 index 00000000..7fc11ff2 --- /dev/null +++ b/lib/RoadizRozierBundle/translations/users/messages.es.xlf @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/lib/RoadizRozierBundle/translations/users/messages.fr.xlf b/lib/RoadizRozierBundle/translations/users/messages.fr.xlf new file mode 100644 index 00000000..41e80283 --- /dev/null +++ b/lib/RoadizRozierBundle/translations/users/messages.fr.xlf @@ -0,0 +1,19 @@ + + + + + + user.lastLogin + Dernière connexion + + + user.neverLoggedIn + Jamais + + + publicName + Nom public + + + + diff --git a/lib/RoadizRozierBundle/translations/users/messages.id.xlf b/lib/RoadizRozierBundle/translations/users/messages.id.xlf new file mode 100644 index 00000000..0cd9129c --- /dev/null +++ b/lib/RoadizRozierBundle/translations/users/messages.id.xlf @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/lib/RoadizRozierBundle/translations/users/messages.it.xlf b/lib/RoadizRozierBundle/translations/users/messages.it.xlf new file mode 100644 index 00000000..5e415520 --- /dev/null +++ b/lib/RoadizRozierBundle/translations/users/messages.it.xlf @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/lib/RoadizRozierBundle/translations/users/messages.ru.xlf b/lib/RoadizRozierBundle/translations/users/messages.ru.xlf new file mode 100644 index 00000000..d0512b9a --- /dev/null +++ b/lib/RoadizRozierBundle/translations/users/messages.ru.xlf @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/lib/RoadizRozierBundle/translations/users/messages.sr.xlf b/lib/RoadizRozierBundle/translations/users/messages.sr.xlf new file mode 100644 index 00000000..0797bec9 --- /dev/null +++ b/lib/RoadizRozierBundle/translations/users/messages.sr.xlf @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/lib/RoadizRozierBundle/translations/users/messages.tr.xlf b/lib/RoadizRozierBundle/translations/users/messages.tr.xlf new file mode 100644 index 00000000..0740d9bd --- /dev/null +++ b/lib/RoadizRozierBundle/translations/users/messages.tr.xlf @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/lib/RoadizRozierBundle/translations/users/messages.uk.xlf b/lib/RoadizRozierBundle/translations/users/messages.uk.xlf new file mode 100644 index 00000000..bd6551c7 --- /dev/null +++ b/lib/RoadizRozierBundle/translations/users/messages.uk.xlf @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/lib/RoadizRozierBundle/translations/users/messages.xlf b/lib/RoadizRozierBundle/translations/users/messages.xlf new file mode 100644 index 00000000..b9f28624 --- /dev/null +++ b/lib/RoadizRozierBundle/translations/users/messages.xlf @@ -0,0 +1,19 @@ + + + + + + user.lastLogin + + + + user.neverLoggedIn + + + + publicName + + + + + diff --git a/lib/RoadizRozierBundle/translations/users/messages.zh.xlf b/lib/RoadizRozierBundle/translations/users/messages.zh.xlf new file mode 100644 index 00000000..33165c70 --- /dev/null +++ b/lib/RoadizRozierBundle/translations/users/messages.zh.xlf @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/lib/RoadizRozierBundle/translations/webhooks/messages.ar.xlf b/lib/RoadizRozierBundle/translations/webhooks/messages.ar.xlf new file mode 100644 index 00000000..e0df9c3d --- /dev/null +++ b/lib/RoadizRozierBundle/translations/webhooks/messages.ar.xlf @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/lib/RoadizRozierBundle/translations/webhooks/messages.de.xlf b/lib/RoadizRozierBundle/translations/webhooks/messages.de.xlf new file mode 100644 index 00000000..8eb957a3 --- /dev/null +++ b/lib/RoadizRozierBundle/translations/webhooks/messages.de.xlf @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/lib/RoadizRozierBundle/translations/webhooks/messages.en.xlf b/lib/RoadizRozierBundle/translations/webhooks/messages.en.xlf new file mode 100644 index 00000000..5e819fb1 --- /dev/null +++ b/lib/RoadizRozierBundle/translations/webhooks/messages.en.xlf @@ -0,0 +1,111 @@ + + + + + + webhooks + Webhooks + + + manage.webhooks + Webhooks + + + add.a.webhook + Add a webhook + + + back_to.webhooks + Back to webhooks list + + + delete.webhook.%name% + Delete this webhook + + + edit.webhook.%name% + Edit this webhook + + + are_you_sure.delete.webhook + Are you sure you want to delete this webhook? + + + webhooks.messageType + Webhook type + + + webhooks.uri + URI + + + webhooks.lastTriggeredAt + Last triggered at + + + webhooks.payload + Payload + + + webhooks.throttleSeconds + Throttling (in seconds) + + + webhooks.rootNode + Root node + + + webhooks.rootNode.help + Limit webhooks triggers to this root node and its children for every node-related updates. + + + never + Never + + + webhook.type.gitlab_pipeline + Gitlab CI Pipeline + + + webhook.type.netlify_build_hook + Netlify Build hook + + + webhook.type.generic_json_post + Generic JSON POST + + + webhook.trigger + Trigger webhook + + + webhook.%item%.will_be_triggered_in.%seconds% + Webhook %item% will be triggered in %seconds% seconds. + + + are_you_sure.trigger.webhook + Are you sure you want to trigger this webhook manually? + + + trigger.webhook.%name% + Trigger webhook manually + + + webhooks.automatic + Automatic trigger + + + webhooks.automatic.help + Webhook will be automatically triggered for each content update (node, tag) + + + webhook.too_many_triggered_in_period + Please wait until %time% before triggering this webhook again. + + + webhooks.description + Description + + + + diff --git a/lib/RoadizRozierBundle/translations/webhooks/messages.es.xlf b/lib/RoadizRozierBundle/translations/webhooks/messages.es.xlf new file mode 100644 index 00000000..0dde1ae6 --- /dev/null +++ b/lib/RoadizRozierBundle/translations/webhooks/messages.es.xlf @@ -0,0 +1,47 @@ + + + + + + manage.webhooks + Andministrar webhooks + + + add.a.webhook + Añadir un webhook + + + back_to.webhooks + Volver a webhooks + + + delete.webhook.%name% + Eliminar webhook %name% + + + edit.webhook.%name% + Editar webhook %name% + + + are_you_sure.delete.webhook + ¿Estás seguro de querer eliminar este webhook? + + + webhooks.messageType + Tipo de mensaje del webhook + + + webhooks.uri + URI del webhook + + + never + Nunca + + + webhooks.description + Descripción del webhook + + + + diff --git a/lib/RoadizRozierBundle/translations/webhooks/messages.fr.xlf b/lib/RoadizRozierBundle/translations/webhooks/messages.fr.xlf new file mode 100644 index 00000000..b0544128 --- /dev/null +++ b/lib/RoadizRozierBundle/translations/webhooks/messages.fr.xlf @@ -0,0 +1,111 @@ + + + + + + webhooks + Webhooks + + + manage.webhooks + Webhooks + + + add.a.webhook + Ajouter un webhook + + + back_to.webhooks + Retour à la liste des webhooks + + + delete.webhook.%name% + Supprimer le webhook + + + edit.webhook.%name% + Éditer le webhook + + + are_you_sure.delete.webhook + Êtes-vous sûr·e de vouloir supprimer ce webhook ? + + + webhooks.messageType + Type de webhook + + + webhooks.uri + URI + + + webhooks.lastTriggeredAt + Dernier déclenchement + + + webhooks.payload + Payload + + + webhooks.throttleSeconds + Temporisation (en secondes) + + + webhooks.rootNode + Nœud racine + + + webhooks.rootNode.help + Limite le déclenchement des webhooks à ce nœud-racine et à sa descendance pour toutes les modifications liée aux nœuds. + + + never + Jamais + + + webhook.type.gitlab_pipeline + Gitlab CI Pipeline + + + webhook.type.netlify_build_hook + Netlify Build hook + + + webhook.type.generic_json_post + POST JSON générique + + + webhook.trigger + Déclencher le webhook + + + webhook.%item%.will_be_triggered_in.%seconds% + Le webhook %item% sera déclenché dans %seconds% secondes. + + + are_you_sure.trigger.webhook + Êtes-vous sûr·e de vouloir déclencher manuellement ce webhook ? + + + trigger.webhook.%name% + Déclencher manuellement le webhook + + + webhooks.automatic + Déclenchement automatique + + + webhooks.automatic.help + Le webhook sera déclenché à chaque édition d'un contenu (nœud, étiquette) + + + webhook.too_many_triggered_in_period + Veuillez patienter jusqu'à %time% avant de déclencher à nouveau ce webhook. + + + webhooks.description + Description + + + + diff --git a/lib/RoadizRozierBundle/translations/webhooks/messages.id.xlf b/lib/RoadizRozierBundle/translations/webhooks/messages.id.xlf new file mode 100644 index 00000000..f71f6afa --- /dev/null +++ b/lib/RoadizRozierBundle/translations/webhooks/messages.id.xlf @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/lib/RoadizRozierBundle/translations/webhooks/messages.it.xlf b/lib/RoadizRozierBundle/translations/webhooks/messages.it.xlf new file mode 100644 index 00000000..f48c6d97 --- /dev/null +++ b/lib/RoadizRozierBundle/translations/webhooks/messages.it.xlf @@ -0,0 +1,11 @@ + + + + + + webhooks + webhook + + + + diff --git a/lib/RoadizRozierBundle/translations/webhooks/messages.ru.xlf b/lib/RoadizRozierBundle/translations/webhooks/messages.ru.xlf new file mode 100644 index 00000000..c36ca229 --- /dev/null +++ b/lib/RoadizRozierBundle/translations/webhooks/messages.ru.xlf @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/lib/RoadizRozierBundle/translations/webhooks/messages.sr.xlf b/lib/RoadizRozierBundle/translations/webhooks/messages.sr.xlf new file mode 100644 index 00000000..ea99bcb6 --- /dev/null +++ b/lib/RoadizRozierBundle/translations/webhooks/messages.sr.xlf @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/lib/RoadizRozierBundle/translations/webhooks/messages.tr.xlf b/lib/RoadizRozierBundle/translations/webhooks/messages.tr.xlf new file mode 100644 index 00000000..4b25909a --- /dev/null +++ b/lib/RoadizRozierBundle/translations/webhooks/messages.tr.xlf @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/lib/RoadizRozierBundle/translations/webhooks/messages.uk.xlf b/lib/RoadizRozierBundle/translations/webhooks/messages.uk.xlf new file mode 100644 index 00000000..373c5385 --- /dev/null +++ b/lib/RoadizRozierBundle/translations/webhooks/messages.uk.xlf @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/lib/RoadizRozierBundle/translations/webhooks/messages.xlf b/lib/RoadizRozierBundle/translations/webhooks/messages.xlf new file mode 100644 index 00000000..4857742d --- /dev/null +++ b/lib/RoadizRozierBundle/translations/webhooks/messages.xlf @@ -0,0 +1,111 @@ + + + + + + webhooks + + + + manage.webhooks + + + + add.a.webhook + + + + back_to.webhooks + + + + delete.webhook.%name% + + + + edit.webhook.%name% + + + + are_you_sure.delete.webhook + + + + webhooks.messageType + + + + webhooks.uri + + + + webhooks.lastTriggeredAt + + + + webhooks.payload + + + + webhooks.throttleSeconds + + + + webhooks.rootNode + + + + webhooks.rootNode.help + + + + never + + + + webhook.type.gitlab_pipeline + + + + webhook.type.netlify_build_hook + + + + webhook.type.generic_json_post + + + + webhook.trigger + + + + webhook.%item%.will_be_triggered_in.%seconds% + + + + are_you_sure.trigger.webhook + + + + trigger.webhook.%name% + + + + webhooks.automatic + + + + webhooks.automatic.help + + + + webhook.too_many_triggered_in_period + + + + webhooks.description + + + + + diff --git a/lib/RoadizRozierBundle/translations/webhooks/messages.zh.xlf b/lib/RoadizRozierBundle/translations/webhooks/messages.zh.xlf new file mode 100644 index 00000000..aa139f60 --- /dev/null +++ b/lib/RoadizRozierBundle/translations/webhooks/messages.zh.xlf @@ -0,0 +1,87 @@ + + + + + + webhooks + Webhooks + + + manage.webhooks + Webhooks + + + add.a.webhook + 添加 webhook + + + back_to.webhooks + 回到 webhook 列表 + + + delete.webhook.%name% + 删除此 webhook + + + edit.webhook.%name% + 编辑此 webhook + + + are_you_sure.delete.webhook + 您确定要删除此 webhook 吗? + + + webhooks.messageType + Webhook 类型 + + + webhooks.uri + URI + + + webhooks.lastTriggeredAt + 最后触发于 + + + webhooks.payload + Payload + + + webhooks.throttleSeconds + 节流 (Throttle)(以秒为单位) + + + never + 从不 + + + webhook.type.gitlab_pipeline + Gitlab CI Pipeline + + + webhook.type.netlify_build_hook + Netlify Build hook + + + webhook.trigger + 触发 webhook + + + are_you_sure.trigger.webhook + 您确定要手动触发此 webhook 吗? + + + trigger.webhook.%name% + 手动触发 webhook + + + webhooks.automatic + 自动触发器 + + + webhook.too_many_triggered_in_period + 在再次触发此 webhook 前,请等待至少30秒。 + + + + diff --git a/lib/RoadizUserBundle b/lib/RoadizUserBundle deleted file mode 160000 index 440bedaf..00000000 --- a/lib/RoadizUserBundle +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 440bedafe4e74bd05ae2f6c8e701b33094bc8928 diff --git a/lib/RoadizUserBundle/.github/workflows/run-test.yml b/lib/RoadizUserBundle/.github/workflows/run-test.yml new file mode 100644 index 00000000..3b17d56e --- /dev/null +++ b/lib/RoadizUserBundle/.github/workflows/run-test.yml @@ -0,0 +1,41 @@ +name: Static analysis and code style + +on: + push: + branches: + - develop + - 'release/**' + - 'hotfix/**' + tags: ['**'] + pull_request: + types: + - opened + - reopened + - synchronize + - ready_for_review + +jobs: + static-analysis-tests: + runs-on: ubuntu-latest + strategy: + matrix: + php-version: ['8.0', '8.1'] + steps: + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + - uses: actions/checkout@v3 + - name: Cache Composer packages + id: composer-cache + uses: actions/cache@v3 + with: + path: vendor + key: ${{ runner.os }}-php-${{ matrix.php-version }}-${{ hashFiles('**/composer.lock') }} + restore-keys: | + ${{ runner.os }}-php-${{ matrix.php-version }}- + - name: Install Dependencies + run: composer install --no-scripts --no-ansi --no-interaction --no-progress + - name: Run PHP Code Sniffer + run: vendor/bin/phpcs -p ./src + - name: Run PHPStan + run: vendor/bin/phpstan analyse --no-progress -c phpstan.neon diff --git a/lib/RoadizUserBundle/.gitignore b/lib/RoadizUserBundle/.gitignore new file mode 100644 index 00000000..e36c4c58 --- /dev/null +++ b/lib/RoadizUserBundle/.gitignore @@ -0,0 +1,126 @@ + +# Created by https://www.toptal.com/developers/gitignore/api/phpstorm+all +# Edit at https://www.toptal.com/developers/gitignore?templates=phpstorm+all + +### PhpStorm+all ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# AWS User-specific +.idea/**/aws.xml + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +### PhpStorm+all Patch ### +# Ignores the whole .idea folder and all .iml files +# See https://github.com/joeblau/gitignore.io/issues/186 and https://github.com/joeblau/gitignore.io/issues/360 + +.idea/ + +# Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-249601023 + +*.iml +modules.xml +.idea/misc.xml +*.ipr + +# Sonarlint plugin +.idea/sonarlint + +# End of https://www.toptal.com/developers/gitignore/api/phpstorm+all + +###> symfony/framework-bundle ### +/.env.local +/.env.local.php +/.env.*.local +/config/secrets/prod/prod.decrypt.private.php +/public/bundles/ +/var/ +/vendor/ +###< symfony/framework-bundle ### + +###> symfony/phpunit-bridge ### +.phpunit.result.cache +/phpunit.xml +###< symfony/phpunit-bridge ### + +###> phpunit/phpunit ### +/phpunit.xml +.phpunit.result.cache +###< phpunit/phpunit ### +/lib/ +/.data/ + +###> squizlabs/php_codesniffer ### +/.phpcs-cache +/phpcs.xml +###< squizlabs/php_codesniffer ### +/report.txt +/composer.lock diff --git a/lib/RoadizUserBundle/.travis.yml b/lib/RoadizUserBundle/.travis.yml new file mode 100644 index 00000000..316f0e9e --- /dev/null +++ b/lib/RoadizUserBundle/.travis.yml @@ -0,0 +1,14 @@ +language: php +php: + - '8.0' + - '8.1' + - 'nightly' +jobs: + allow_failures: + - php: 'nightly' +install: + - composer install --dev --no-scripts --no-suggest + +script: + - vendor/bin/phpcs -p ./src + - vendor/bin/phpstan analyse -c phpstan.neon diff --git a/lib/RoadizUserBundle/LICENSE.md b/lib/RoadizUserBundle/LICENSE.md new file mode 100644 index 00000000..d4d8a009 --- /dev/null +++ b/lib/RoadizUserBundle/LICENSE.md @@ -0,0 +1,9 @@ +The MIT License (MIT) + +Copyright © 2023 Ambroise Maupate + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/lib/RoadizUserBundle/Makefile b/lib/RoadizUserBundle/Makefile new file mode 100644 index 00000000..16ef8148 --- /dev/null +++ b/lib/RoadizUserBundle/Makefile @@ -0,0 +1,3 @@ +test: + php -d "memory_limit=-1" vendor/bin/phpcbf --report=full --report-file=./report.txt -p ./src + php -d "memory_limit=-1" vendor/bin/phpstan analyse -c phpstan.neon diff --git a/lib/RoadizUserBundle/README.md b/lib/RoadizUserBundle/README.md new file mode 100644 index 00000000..73e43be4 --- /dev/null +++ b/lib/RoadizUserBundle/README.md @@ -0,0 +1,110 @@ +# Roadiz User bundle +**Public user management bundle for Roadiz v2** + +![Run test status](https://github.com/roadiz/user-bundle/actions/workflows/run-test.yml/badge.svg?branch=develop) + +Installation +============ + +Make sure Composer is installed globally, as explained in the +[installation chapter](https://getcomposer.org/doc/00-intro.md) +of the Composer documentation. + +Applications that use Symfony Flex +---------------------------------- + +Open a command console, enter your project directory and execute: + +```console +$ composer require roadiz/user-bundle +``` + +Applications that don't use Symfony Flex +---------------------------------------- + +### Step 1: Download the Bundle + +Open a command console, enter your project directory and execute the +following command to download the latest stable version of this bundle: + +```console +$ composer require roadiz/user-bundle +``` + +### Step 2: Enable the Bundle + +Then, enable the bundle by adding it to the list of registered bundles +in the `config/bundles.php` file of your project: + +```php +// config/bundles.php + +return [ + // ... + \RZ\Roadiz\UserBundle\RoadizUserBundle::class => ['all' => true], +]; +``` + +## Configuration + +- Copy *API Platform* resource configuration file: `./config/api_resources/user.yaml` to your Roadiz project `api_resource` folder. +- Edit your `./config/packages/framework.yaml` file with: +```yaml +framework: + rate_limiter: + user_signup: + policy: 'token_bucket' + limit: 5 + rate: { interval: '1 minutes', amount: 3 } + cache_pool: 'cache.user_signup_limiter' + password_request: + policy: 'token_bucket' + limit: 3 + rate: { interval: '1 minutes', amount: 3 } + cache_pool: 'cache.password_request_limiter' + password_reset: + policy: 'token_bucket' + limit: 3 + rate: { interval: '1 minutes', amount: 3 } + cache_pool: 'cache.password_reset_limiter' +``` +- Edit your `./config/packages/cache.yaml` file with: +```yaml +framework: + cache: + pools: + cache.user_signup_limiter: ~ + cache.password_request_limiter: ~ + cache.password_reset_limiter: ~ +``` +- Edit your `./config/packages/security.yaml` file with: +```yaml +security: + access_control: + # Append user routes configuration + - { path: "^/api/users/signup", methods: [ POST ], roles: IS_AUTHENTICATED_ANONYMOUSLY } + - { path: "^/api/users/password_request", methods: [ POST ], roles: IS_AUTHENTICATED_ANONYMOUSLY } + - { path: "^/api/users/password_reset", methods: [ PUT ], roles: IS_AUTHENTICATED_ANONYMOUSLY } + - { path: "^/api/users", methods: [ GET, PUT, PATCH, POST ], roles: ROLE_USER } +``` +- Edit your `./.env` file with: +```dotenv +USER_PASSWORD_RESET_URL=https://your-public-url.test/reset +USER_VALIDATION_URL=https://your-public-url.test/validate +USER_PASSWORD_RESET_EXPIRES_IN=600 +USER_VALIDATION_EXPIRES_IN=3600 +``` +- Update your CORS configuration with additional headers `Www-Authenticate` and `x-g-recaptcha-response`: +```yaml +# config/packages/nelmio_cors.yaml +nelmio_cors: + defaults: + # ... + allow_headers: ['Content-Type', 'Authorization', 'Www-Authenticate', 'x-g-recaptcha-response'] + expose_headers: ['Link', 'Www-Authenticate'] +``` + + +## Maintenance commands + +- `bin/console users:purge-validation-tokens`: Delete all expired user validation tokens diff --git a/lib/RoadizUserBundle/composer.json b/lib/RoadizUserBundle/composer.json new file mode 100644 index 00000000..0dca12ec --- /dev/null +++ b/lib/RoadizUserBundle/composer.json @@ -0,0 +1,57 @@ +{ + "name": "roadiz/user-bundle", + "license": "MIT", + "keywords": [ + "cms", + "backoffice", + "rezo zero" + ], + "authors": [ + { + "name": "Ambroise Maupate", + "email": "ambroise@roadiz.io", + "homepage": "https://www.roadiz.io", + "role": "Lead developer" + } + ], + "type": "symfony-bundle", + "minimum-stability": "dev", + "require": { + "php": ">=8.0", + "api-platform/core": "~2.7.0", + "symfony/framework-bundle": "5.4.*", + "symfony/rate-limiter": "5.4.*", + "doctrine/orm": "^2.14.1" + }, + "require-dev": { + "roadiz/core-bundle": "2.1.x-dev", + "php-coveralls/php-coveralls": "^2.4", + "phpstan/phpstan": "^1.5.3", + "squizlabs/php_codesniffer": "^3.5", + "phpstan/phpstan-doctrine": "^1.3" + }, + "config": { + "optimize-autoloader": true, + "preferred-install": { + "*": "dist" + }, + "sort-packages": true, + "allow-plugins": { + "symfony/flex": false, + "symfony/runtime": true, + "composer/package-versions-deprecated": true, + "php-http/discovery": false + } + }, + "autoload": { + "psr-4": { + "RZ\\Roadiz\\UserBundle\\": "src/" + } + }, + "extra": { + "branch-alias": { + "dev-main": "2.0.x-dev", + "dev-develop": "2.1.x-dev" + } + } +} diff --git a/lib/RoadizUserBundle/config/api_resources/user.yaml b/lib/RoadizUserBundle/config/api_resources/user.yaml new file mode 100644 index 00000000..37e4c47c --- /dev/null +++ b/lib/RoadizUserBundle/config/api_resources/user.yaml @@ -0,0 +1,105 @@ +--- +RZ\Roadiz\CoreBundle\Entity\User: + iri: User + shortName: User + attributes: + cache_headers: + public: false + max_age: 0 + collectionOperations: + signup: + method: 'POST' + path: '/users/signup' + controller: RZ\Roadiz\UserBundle\Controller\SignupController + input: RZ\Roadiz\UserBundle\Api\Dto\UserInput + output: RZ\Roadiz\UserBundle\Api\Dto\VoidOutput + validation_groups: + - no_empty_password + openapi_context: + summary: Create a new public user + parameters: + - in: header + name: x-g-recaptcha-response + schema: + type: string + required: true + description: | + Create a new public user. User won't be validated and will not be granted with any role. + This operation may require a *Google Recaptcha* response to protect against flooding. + + password_request: + method: 'POST' + path: '/users/password_request' + controller: RZ\Roadiz\UserBundle\Controller\PasswordRequestController + input: RZ\Roadiz\UserBundle\Api\Dto\UserPasswordRequestInput + output: RZ\Roadiz\UserBundle\Api\Dto\VoidOutput + # Password request must not call WriteListener to let PasswordRequestController persist changes. + write: false + validate: false + openapi_context: + summary: Request a public user new password + parameters: + - in: header + name: x-g-recaptcha-response + schema: + type: string + required: true + description: | + Initiate a public user new password request (forgot my password). This operation may + require a *Google Recaptcha* response to protect against flooding. + + validation_request: + method: 'POST' + path: '/users/validation_request' + controller: RZ\Roadiz\UserBundle\Controller\ValidationRequestController + input: RZ\Roadiz\UserBundle\Api\Dto\UserValidationRequestInput + output: RZ\Roadiz\UserBundle\Api\Dto\VoidOutput + # Validation request must not call WriteListener to let ValidationRequestController persist changes. + write: false + validate: false + openapi_context: + summary: Request a public user email validation token + description: | + Initiate a public user validation request (to verify user email address) + + itemOperations: + information: + method: 'GET' + read: false + path: '/users/me' + controller: RZ\Roadiz\UserBundle\Controller\InformationController + output: RZ\Roadiz\UserBundle\Api\Dto\UserOutput + openapi_context: + summary: Get current user (JWT) information + description: | + Get current user (JWT) information + + password_reset: + method: 'PUT' + path: '/users/password_reset' + controller: RZ\Roadiz\UserBundle\Controller\PasswordResetController + input: RZ\Roadiz\UserBundle\Api\Dto\UserPasswordTokenInput + output: RZ\Roadiz\UserBundle\Api\Dto\VoidOutput + # Password reset must not call ReadListener to let DataTransformer provide User. + read: false + validate: false + validation_groups: + - no_empty_password + openapi_context: + summary: Reset a public user password + parameters: ~ + description: | + Change a public user password against a unique temporary token (forgot my password) + + validate: + method: 'PUT' + path: '/users/validate' + controller: RZ\Roadiz\UserBundle\Controller\ValidateController + input: RZ\Roadiz\UserBundle\Api\Dto\UserTokenInput + output: RZ\Roadiz\UserBundle\Api\Dto\VoidOutput + read: false + validate: false + openapi_context: + summary: Validate a public user email + description: | + Validate a public user email with a unique and temporary token diff --git a/lib/RoadizUserBundle/config/packages/roadiz_user.yaml b/lib/RoadizUserBundle/config/packages/roadiz_user.yaml new file mode 100644 index 00000000..3050ee9a --- /dev/null +++ b/lib/RoadizUserBundle/config/packages/roadiz_user.yaml @@ -0,0 +1,20 @@ +parameters: + env(USER_PASSWORD_RESET_URL): 'loginResetPage' + env(USER_VALIDATION_URL): 'http://example.test/my-account/validate' + env(USER_PASSWORD_RESET_EXPIRES_IN): '600' + env(USER_VALIDATION_EXPIRES_IN): '3600' + +# Default configuration for "RoadizUserBundle" +roadiz_user: + # Define frontend URL to redirect user to after receiving its password recovery email. + # **This parameter supports Symfony routes name as well as hard-coded URLs.** + password_reset_url: '%env(string:USER_PASSWORD_RESET_URL)%' + # Define frontend URL to redirect user to after receiving its email validation request. + # **This parameter supports Symfony routes name as well as hard-coded URLs.** + user_validation_url: '%env(string:USER_VALIDATION_URL)%' + # Define password recovery expiring time in seconds. + password_reset_expires_in: '%env(int:USER_PASSWORD_RESET_EXPIRES_IN)%' + # Define user validation token expiring time in seconds. + user_validation_expires_in: '%env(int:USER_VALIDATION_EXPIRES_IN)%' + public_user_role_name: ROLE_PUBLIC_USER + email_validated_role_name: ROLE_EMAIL_VALIDATED diff --git a/lib/RoadizUserBundle/config/routing.yaml b/lib/RoadizUserBundle/config/routing.yaml new file mode 100644 index 00000000..fe13e99e --- /dev/null +++ b/lib/RoadizUserBundle/config/routing.yaml @@ -0,0 +1 @@ +# see api_resources/user.yaml for API Platform auto routing diff --git a/lib/RoadizUserBundle/config/services.yaml b/lib/RoadizUserBundle/config/services.yaml new file mode 100644 index 00000000..c3c99abd --- /dev/null +++ b/lib/RoadizUserBundle/config/services.yaml @@ -0,0 +1,31 @@ +--- +parameters: + env(USER_PASSWORD_RESET_URL): 'loginResetPage' + env(USER_VALIDATION_URL): 'http://example.test/my-account/validate' + env(USER_PASSWORD_RESET_EXPIRES_IN): '600' + env(USER_VALIDATION_EXPIRES_IN): '3600' + +services: + # default configuration for services in *this* file + _defaults: + autowire: true # Automatically injects dependencies in your services. + autoconfigure: true # Automatically registers your services as commands, event subscribers, etc. + bind: + $passwordResetUrl: '%roadiz_user.password_reset_url%' + $userValidationUrl: '%roadiz_user.user_validation_url%' + $passwordResetExpiresIn: '%roadiz_user.password_reset_expires_in%' + $userValidationExpiresIn: '%roadiz_user.user_validation_expires_in%' + $publicUserRoleName: '%roadiz_user.public_user_role_name%' + $emailValidatedRoleName: '%roadiz_user.email_validated_role_name%' + + RZ\Roadiz\UserBundle\: + resource: '../src/' + exclude: + - '../src/DependencyInjection/' + - '../src/Entity/' + - '../src/Tests/' + - '../src/Event/' + + RZ\Roadiz\UserBundle\Controller\: + resource: '../src/Controller/' + tags: ['controller.service_arguments'] diff --git a/lib/RoadizUserBundle/crowdin.yml b/lib/RoadizUserBundle/crowdin.yml new file mode 100644 index 00000000..22b72c45 --- /dev/null +++ b/lib/RoadizUserBundle/crowdin.yml @@ -0,0 +1,8 @@ +files: + - source: /translations/**/*.xlf + ignore: + - /**/*.%two_letters_code%.xlf + - /**/*.%locale_with_underscore%.xlf + - /**/*.%locale%.xlf + - /**/*.sr_Cyrl.xlf + translation: /%original_path%/%file_name%.%two_letters_code%.%file_extension% diff --git a/lib/RoadizUserBundle/migrations/Version20220613144815.php b/lib/RoadizUserBundle/migrations/Version20220613144815.php new file mode 100644 index 00000000..9972af63 --- /dev/null +++ b/lib/RoadizUserBundle/migrations/Version20220613144815.php @@ -0,0 +1,32 @@ +addSql('CREATE TABLE user_validation_tokens (id INT AUTO_INCREMENT NOT NULL, user_id INT DEFAULT NULL, token VARCHAR(255) NOT NULL, token_valid_until DATETIME DEFAULT NULL, UNIQUE INDEX UNIQ_D613F87E5F37A13B (token), INDEX IDX_D613F87EA76ED395 (user_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('ALTER TABLE user_validation_tokens ADD CONSTRAINT FK_D613F87EA76ED395 FOREIGN KEY (user_id) REFERENCES users (id)'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('DROP TABLE user_validation_tokens'); + } +} diff --git a/lib/RoadizUserBundle/migrations/Version20220615142220.php b/lib/RoadizUserBundle/migrations/Version20220615142220.php new file mode 100644 index 00000000..eb151fa8 --- /dev/null +++ b/lib/RoadizUserBundle/migrations/Version20220615142220.php @@ -0,0 +1,33 @@ +addSql('ALTER TABLE user_validation_tokens DROP FOREIGN KEY FK_D613F87EA76ED395'); + $this->addSql('ALTER TABLE user_validation_tokens ADD CONSTRAINT FK_D613F87EA76ED395 FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE user_validation_tokens DROP FOREIGN KEY FK_D613F87EA76ED395'); + $this->addSql('ALTER TABLE user_validation_tokens ADD CONSTRAINT FK_D613F87EA76ED395 FOREIGN KEY (user_id) REFERENCES users (id) ON UPDATE NO ACTION ON DELETE NO ACTION'); + } +} diff --git a/lib/RoadizUserBundle/migrations/Version20220718100618.php b/lib/RoadizUserBundle/migrations/Version20220718100618.php new file mode 100644 index 00000000..0b487e2b --- /dev/null +++ b/lib/RoadizUserBundle/migrations/Version20220718100618.php @@ -0,0 +1,32 @@ +addSql('CREATE TABLE user_metadata (id INT AUTO_INCREMENT NOT NULL, user_id INT DEFAULT NULL, metadata JSON DEFAULT NULL, UNIQUE INDEX UNIQ_AF99D014A76ED395 (user_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('ALTER TABLE user_metadata ADD CONSTRAINT FK_AF99D014A76ED395 FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('DROP TABLE user_metadata'); + } +} diff --git a/lib/RoadizUserBundle/phpcs.xml.dist b/lib/RoadizUserBundle/phpcs.xml.dist new file mode 100644 index 00000000..19bff0cc --- /dev/null +++ b/lib/RoadizUserBundle/phpcs.xml.dist @@ -0,0 +1,13 @@ + + + + + + + + + + + src/ + diff --git a/lib/RoadizUserBundle/phpstan.neon b/lib/RoadizUserBundle/phpstan.neon new file mode 100644 index 00000000..675f70d0 --- /dev/null +++ b/lib/RoadizUserBundle/phpstan.neon @@ -0,0 +1,34 @@ +parameters: + level: 5 + paths: + - src + excludePaths: + - */node_modules/* + - */bower_components/* + - */static/* + ignoreErrors: + - '#Call to an undefined method RZ\\Roadiz\\CoreBundle\\Repository#' + - '#Call to an undefined method RZ\\Roadiz\\UserBundle\\Repository#' + - '#Call to an undefined method Doctrine\\Persistence\\ObjectRepository#' + - '#Call to an undefined method Doctrine\\Persistence\\ObjectManager#' + - '#Call to an undefined method Doctrine\\ORM\\EntityRepository#' + - '#Call to an undefined method Symfony\\Component\\Config\\Definition\\Builder\\NodeDefinition::#' + - '#Access to an undefined property Symfony\\Component\\Validator\\Constraint::#' + - '#RZ\\Roadiz\\Core\\Entities\\Tag does not have a field named \$translation#' + - '#does not have a field named \$node\.home#' + - '#does not have a field named \$node\.id#' + - '#does not have a field named \$node\.parent#' + - '#does not have a field named \$translation#' + # PHPStan Doctrine does not support ResolveTargetEntityListener + - '#Property ([a-zA-Z\\\:\$]+) type mapping mismatch: property can contain ([a-zA-Z\\\&\>\<]+)Interface\>?\|null but database expects ([a-zA-Z\\\&\>\<]+)\|null#' + - '#Property ([a-zA-Z\\\:\$]+) type mapping mismatch: property can contain ([a-zA-Z\\\&\>\<]+)Interface\>? but database expects ([a-zA-Z\\\&\>\<]+)#' + - '#type mapping mismatch: database can contain array\|bool\|float\|int\|JsonSerializable\|stdClass\|string\|null but property expects array\|null#' + - '#Doctrine\\ORM\\Mapping\\GeneratedValue constructor expects#' + + reportUnmatchedIgnoredErrors: false + checkGenericClassInNonGenericObjectType: false + checkMissingIterableValueType: false + doctrine: + repositoryClass: RZ\Roadiz\CoreBundle\Repository\EntityRepository +includes: + - vendor/phpstan/phpstan-doctrine/extension.neon diff --git a/lib/RoadizUserBundle/src/Api/DataTransformer/UserInputDataTransformer.php b/lib/RoadizUserBundle/src/Api/DataTransformer/UserInputDataTransformer.php new file mode 100644 index 00000000..7f156c1b --- /dev/null +++ b/lib/RoadizUserBundle/src/Api/DataTransformer/UserInputDataTransformer.php @@ -0,0 +1,63 @@ +rolesBag = $rolesBag; + $this->publicUserRoleName = $publicUserRoleName; + $this->userMetadataManager = $userMetadataManager; + } + + public function transform($object, string $to, array $context = []): User + { + if (!$object instanceof UserInput) { + throw new \RuntimeException(sprintf('Cannot transform %s to %s', get_class($object), $to)); + } + + $user = new User(); + $user->setEmail($object->email); + $user->setUsername($object->email); + $user->setFirstName($object->firstName); + $user->setPublicName($object->publicName); + $user->setLastName($object->lastName); + $user->setPhone($object->phone); + $user->setCompany($object->company); + $user->setJob($object->job); + $user->setBirthday($object->birthday); + $user->setPlainPassword($object->plainPassword); + $user->addRoleEntity($this->rolesBag->get($this->publicUserRoleName)); + $user->sendCreationConfirmationEmail(true); + + if (null !== $object->metadata) { + $userMetadata = $this->userMetadataManager->createMetadataForUser($user); + $userMetadata->setMetadata($object->metadata); + } + + return $user; + } + + public function supportsTransformation($data, string $to, array $context = []): bool + { + if ($data instanceof UserInterface) { + return false; + } + + return User::class === $to && UserInput::class === ($context['input']['class'] ?? null); + } +} diff --git a/lib/RoadizUserBundle/src/Api/DataTransformer/UserOutputDataTransformer.php b/lib/RoadizUserBundle/src/Api/DataTransformer/UserOutputDataTransformer.php new file mode 100644 index 00000000..dde66abd --- /dev/null +++ b/lib/RoadizUserBundle/src/Api/DataTransformer/UserOutputDataTransformer.php @@ -0,0 +1,70 @@ +userValidationTokenManager = $userValidationTokenManager; + $this->userMetadataManager = $userMetadataManager; + } + + /** + * @inheritDoc + */ + public function transform($object, string $to, array $context = []): UserOutput + { + if (!$object instanceof UserInterface) { + throw new \RuntimeException(sprintf('Cannot transform %s to %s', get_class($object), $to)); + } + + $userOutput = new UserOutput(); + $userOutput->identifier = $object->getUserIdentifier(); + $userOutput->roles = array_values($object->getRoles()); + + if ($object instanceof AbstractHuman) { + $userOutput->publicName = $object->getPublicName(); + $userOutput->firstName = $object->getFirstName(); + $userOutput->lastName = $object->getLastName(); + $userOutput->phone = $object->getPhone(); + $userOutput->company = $object->getCompany(); + $userOutput->job = $object->getJob(); + $userOutput->birthday = $object->getBirthday(); + } + if ($object instanceof User) { + $userOutput->locale = $object->getLocale(); + $userOutput->pictureUrl = $object->getPictureUrl(); + + if (null !== $userMetadata = $this->userMetadataManager->getMetadataForUser($object)) { + $userOutput->metadata = $userMetadata->getMetadata(); + } + } + + $userOutput->emailValidated = $this->userValidationTokenManager->isUserEmailValidated($object); + return $userOutput; + } + + /** + * @inheritDoc + */ + public function supportsTransformation($data, string $to, array $context = []): bool + { + return $to === UserOutput::class && $data instanceof UserInterface; + } +} diff --git a/lib/RoadizUserBundle/src/Api/DataTransformer/UserPasswordRequestInputDataTransformer.php b/lib/RoadizUserBundle/src/Api/DataTransformer/UserPasswordRequestInputDataTransformer.php new file mode 100644 index 00000000..2bac7eab --- /dev/null +++ b/lib/RoadizUserBundle/src/Api/DataTransformer/UserPasswordRequestInputDataTransformer.php @@ -0,0 +1,60 @@ +userProvider = $userProvider; + } + + /** + * @inheritDoc + */ + public function transform($object, string $to, array $context = []): ?User + { + if (!$object instanceof UserPasswordRequestInput) { + throw new \RuntimeException(sprintf('Cannot transform %s to %s', get_class($object), $to)); + } + + try { + $user = $this->userProvider->loadUserByIdentifier($object->identifier); + + if ( + $user instanceof User + && $user->isEnabled() + && $user->isAccountNonExpired() + && $user->isAccountNonLocked() + ) { + return $user; + } + } catch (AuthenticationException $exception) { + } + + return null; + } + + /** + * @inheritDoc + */ + public function supportsTransformation($data, string $to, array $context = []): bool + { + if ($data instanceof UserInterface) { + return false; + } + + return User::class === $to && UserPasswordRequestInput::class === ($context['input']['class'] ?? null); + } +} diff --git a/lib/RoadizUserBundle/src/Api/DataTransformer/UserPasswordTokenInputDataTransformer.php b/lib/RoadizUserBundle/src/Api/DataTransformer/UserPasswordTokenInputDataTransformer.php new file mode 100644 index 00000000..3bef107d --- /dev/null +++ b/lib/RoadizUserBundle/src/Api/DataTransformer/UserPasswordTokenInputDataTransformer.php @@ -0,0 +1,101 @@ +passwordResetExpiresIn = $passwordResetExpiresIn; + $this->managerRegistry = $managerRegistry; + $this->passwordResetLimiter = $passwordResetLimiter; + $this->requestStack = $requestStack; + $this->validator = $validator; + } + + /** + * @inheritDoc + */ + public function transform($object, string $to, array $context = []): User + { + if (!$object instanceof UserPasswordTokenInput) { + throw new \RuntimeException(sprintf('Cannot transform %s to %s', get_class($object), $to)); + } + + $user = $this->managerRegistry + ->getRepository(User::class) + ->findOneByConfirmationToken($object->token); + + $request = $this->requestStack->getCurrentRequest(); + if (null !== $request) { + $limiter = $this->passwordResetLimiter->create($request->getClientIp()); + $limit = $limiter->consume(); + if (false === $limit->isAccepted()) { + throw new TooManyRequestsHttpException($limit->getRetryAfter()->getTimestamp()); + } + } + + if (!$user instanceof User) { + throw new NotFoundHttpException('User does not exist.'); + } + + if ( + $user->isEnabled() + && $user->isAccountNonExpired() + && $user->isAccountNonLocked() + ) { + $expiresAt = clone $user->getPasswordRequestedAt(); + $expiresAt->add(new \DateInterval(sprintf('PT%dS', $this->passwordResetExpiresIn))); + + if ($expiresAt <= new \DateTime()) { + throw new UnprocessableEntityHttpException('Token is not valid anymore.'); + } + + $user->setPlainPassword($object->plainPassword); + $this->validator->validate($user); + + $user->setPasswordRequestedAt(null); + $user->setConfirmationToken(null); + return $user; + } + + throw new UnprocessableEntityHttpException('User is disabled, locked or expired.'); + } + + /** + * @inheritDoc + */ + public function supportsTransformation($data, string $to, array $context = []): bool + { + if ($data instanceof UserInterface) { + return false; + } + + return User::class === $to && UserPasswordTokenInput::class === ($context['input']['class'] ?? null); + } +} diff --git a/lib/RoadizUserBundle/src/Api/DataTransformer/UserTokenInputDataTransformer.php b/lib/RoadizUserBundle/src/Api/DataTransformer/UserTokenInputDataTransformer.php new file mode 100644 index 00000000..49bbdbff --- /dev/null +++ b/lib/RoadizUserBundle/src/Api/DataTransformer/UserTokenInputDataTransformer.php @@ -0,0 +1,91 @@ +managerRegistry = $managerRegistry; + $this->rolesBag = $rolesBag; + $this->emailValidatedRoleName = $emailValidatedRoleName; + $this->security = $security; + } + + /** + * @inheritDoc + */ + public function transform($object, string $to, array $context = []): User + { + if (!$object instanceof UserTokenInput) { + throw new \RuntimeException(sprintf('Cannot transform %s to %s', get_class($object), $to)); + } + + if (!$this->security->isGranted('ROLE_USER')) { + throw new AccessDeniedHttpException('User must logged in to validate its account'); + } + + $userValidationToken = $this->managerRegistry + ->getRepository(UserValidationToken::class) + ->findOneByValidToken($object->token); + + if (null === $userValidationToken) { + throw new UnprocessableEntityHttpException('Token does not exist or is not valid anymore.'); + } + if (null === $userValidationToken->getUser()) { + throw new UnprocessableEntityHttpException('Token is not linked to any user.'); + } + + $user = $userValidationToken->getUser(); + + if ($this->security->getUser()->getUserIdentifier() !== $user->getUserIdentifier()) { + throw new AccessDeniedHttpException('Token does not belong to current account'); + } + + if ( + $user->isEnabled() + && $user->isAccountNonExpired() + && $user->isAccountNonLocked() + ) { + $user->addRoleEntity($this->rolesBag->get($this->emailValidatedRoleName)); + $this->managerRegistry->getManager()->remove($userValidationToken); + return $user; + } + + throw new UnprocessableEntityHttpException('User is disabled, locked or expired.'); + } + + /** + * @inheritDoc + */ + public function supportsTransformation($data, string $to, array $context = []): bool + { + if ($data instanceof UserInterface) { + return false; + } + + return User::class === $to && UserTokenInput::class === ($context['input']['class'] ?? null); + } +} diff --git a/lib/RoadizUserBundle/src/Api/DataTransformer/UserValidationRequestInputDataTransformer.php b/lib/RoadizUserBundle/src/Api/DataTransformer/UserValidationRequestInputDataTransformer.php new file mode 100644 index 00000000..7f65cb5b --- /dev/null +++ b/lib/RoadizUserBundle/src/Api/DataTransformer/UserValidationRequestInputDataTransformer.php @@ -0,0 +1,45 @@ +userProvider = $userProvider; + } + + /** + * @inheritDoc + */ + public function transform($object, string $to, array $context = []): UserInterface + { + if (!$object instanceof UserValidationRequestInput) { + throw new \RuntimeException(sprintf('Cannot transform %s to %s', get_class($object), $to)); + } + + return $this->userProvider->loadUserByIdentifier($object->identifier); + } + + /** + * @inheritDoc + */ + public function supportsTransformation($data, string $to, array $context = []): bool + { + if ($data instanceof UserInterface) { + return false; + } + + return User::class === $to && UserValidationRequestInput::class === ($context['input']['class'] ?? null); + } +} diff --git a/lib/RoadizUserBundle/src/Api/DataTransformer/VoidOutputDataTransformer.php b/lib/RoadizUserBundle/src/Api/DataTransformer/VoidOutputDataTransformer.php new file mode 100644 index 00000000..a83ac4b4 --- /dev/null +++ b/lib/RoadizUserBundle/src/Api/DataTransformer/VoidOutputDataTransformer.php @@ -0,0 +1,27 @@ +managerRegistry = $managerRegistry; + } + + protected function configure(): void + { + $this + ->setName('users:purge-validation-tokens') + ->setDescription('Purge expired user validation tokens.'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $count = $this->managerRegistry->getRepository(UserValidationToken::class)->deleteAllExpired(); + + $io->success(sprintf('%d expired user validation token(s) were deleted.', $count)); + return 0; + } +} diff --git a/lib/RoadizUserBundle/src/Controller/InformationController.php b/lib/RoadizUserBundle/src/Controller/InformationController.php new file mode 100644 index 00000000..26627f49 --- /dev/null +++ b/lib/RoadizUserBundle/src/Controller/InformationController.php @@ -0,0 +1,33 @@ +security = $security; + } + + public function __invoke(): UserInterface + { + $user = $this->security->getUser(); + + if (null === $user) { + throw new NotFoundHttpException('No user found in request'); + } + + return $user; + } +} diff --git a/lib/RoadizUserBundle/src/Controller/PasswordRequestController.php b/lib/RoadizUserBundle/src/Controller/PasswordRequestController.php new file mode 100644 index 00000000..ff728a2a --- /dev/null +++ b/lib/RoadizUserBundle/src/Controller/PasswordRequestController.php @@ -0,0 +1,156 @@ +logger = $logger; + $this->passwordRequestLimiter = $passwordRequestLimiter; + $this->managerRegistry = $managerRegistry; + $this->emailManager = $emailManager; + $this->passwordResetUrl = $passwordResetUrl; + $this->settingsBag = $settingsBag; + $this->translator = $translator; + $this->urlGenerator = $urlGenerator; + $this->recaptchaService = $recaptchaService; + $this->recaptchaHeaderName = $recaptchaHeaderName; + } + + protected function getRecaptchaService(): RecaptchaServiceInterface + { + return $this->recaptchaService; + } + + protected function getSettingsBag(): Settings + { + return $this->settingsBag; + } + + protected function getRecaptchaHeaderName(): string + { + return $this->recaptchaHeaderName; + } + + public function __invoke(Request $request, ?User $data): User + { + $limiter = $this->passwordRequestLimiter->create($request->getClientIp()); + $limit = $limiter->consume(); + if (false === $limit->isAccepted()) { + throw new TooManyRequestsHttpException($limit->getRetryAfter()->getTimestamp()); + } + + $this->validateRecaptchaHeader($request); + + /* + * Do not output anything if user exists or not to prevent search attacks. + */ + if ($data === null) { + return new User(); + } + + try { + $tokenGenerator = new TokenGenerator($this->logger); + $data->setPasswordRequestedAt(new \DateTime()); + $data->setConfirmationToken($tokenGenerator->generateToken()); + $this->sendPasswordResetLink($request, $data); + } catch (\Exception $e) { + $data->setPasswordRequestedAt(null); + $data->setConfirmationToken(null); + $this->logger->error($e->getMessage()); + } + /* + * This operation should not call WriteListener + * Make sure you configured: `write: false` + */ + $this->managerRegistry->getManager()->flush(); + return $data; + } + + private function sendPasswordResetLink(Request $request, User $user): void + { + $emailContact = $this->settingsBag->get('email_sender'); + $siteName = $this->settingsBag->get('site_name'); + + /* + * Support routes name as well as hard-coded URLs + */ + try { + $resetLink = $this->urlGenerator->generate( + $this->passwordResetUrl, + [ + 'token' => $user->getConfirmationToken(), + '_locale' => $request->getLocale(), + ], + UrlGeneratorInterface::ABSOLUTE_URL + ); + } catch (RouteNotFoundException $exception) { + $resetLink = $this->passwordResetUrl . '?' . http_build_query( + [ + 'token' => $user->getConfirmationToken(), + '_locale' => $request->getLocale(), + ] + ); + } + + $this->emailManager->setAssignation( + [ + 'resetLink' => $resetLink, + 'user' => $user, + 'site' => $siteName, + 'mailContact' => $emailContact, + ] + ); + $this->emailManager->setEmailTemplate('@RoadizUser/email/users/reset_password_email.html.twig'); + $this->emailManager->setEmailPlainTextTemplate('@RoadizUser/email/users/reset_password_email.txt.twig'); + $this->emailManager->setSubject( + $this->translator->trans( + 'reset.password.request' + ) + ); + $this->emailManager->setReceiver($user->getEmail()); + $this->emailManager->setSender(new Address($emailContact, $siteName ?? '')); + $this->emailManager->send(); + } +} diff --git a/lib/RoadizUserBundle/src/Controller/PasswordResetController.php b/lib/RoadizUserBundle/src/Controller/PasswordResetController.php new file mode 100644 index 00000000..9c36825e --- /dev/null +++ b/lib/RoadizUserBundle/src/Controller/PasswordResetController.php @@ -0,0 +1,15 @@ +headers->get($this->getRecaptchaHeaderName(), null); + if (null === $responseValue) { + throw new BadRequestHttpException(sprintf('You must provide %s header for human verification.', $this->getRecaptchaHeaderName())); + } + if (true !== $response = $this->getRecaptchaService()->check($responseValue)) { + if (\is_string($response)) { + throw new BadRequestHttpException($this->getRecaptchaHeaderName() . ': ' . $response); + } elseif (\is_array($response)) { + throw new BadRequestHttpException($this->getRecaptchaHeaderName() . ': ' . reset($response)); + } + throw new BadRequestHttpException($this->getRecaptchaHeaderName() . ': Recaptcha response is not valid.'); + } + } +} diff --git a/lib/RoadizUserBundle/src/Controller/SignupController.php b/lib/RoadizUserBundle/src/Controller/SignupController.php new file mode 100644 index 00000000..88abea76 --- /dev/null +++ b/lib/RoadizUserBundle/src/Controller/SignupController.php @@ -0,0 +1,83 @@ +validator = $validator; + $this->security = $security; + $this->eventDispatcher = $eventDispatcher; + $this->userSignupLimiter = $userSignupLimiter; + $this->recaptchaService = $recaptchaService; + $this->settingsBag = $settingsBag; + $this->recaptchaHeaderName = $recaptchaHeaderName; + } + + protected function getRecaptchaService(): RecaptchaServiceInterface + { + return $this->recaptchaService; + } + + protected function getSettingsBag(): Settings + { + return $this->settingsBag; + } + + protected function getRecaptchaHeaderName(): string + { + return $this->recaptchaHeaderName; + } + + public function __invoke(Request $request, User $data): User + { + if ($this->security->isGranted('ROLE_USER')) { + throw new AccessDeniedHttpException('Cannot sign-up: you\'re already authenticated.'); + } + $limiter = $this->userSignupLimiter->create($request->getClientIp()); + $limit = $limiter->consume(); + if (false === $limit->isAccepted()) { + throw new TooManyRequestsHttpException($limit->getRetryAfter()->getTimestamp()); + } + + $this->validateRecaptchaHeader($request); + + $this->validator->validate($data); + $data->setLocale($request->getLocale()); + $this->eventDispatcher->dispatch(new UserSignedUp($data)); + + return $data; + } +} diff --git a/lib/RoadizUserBundle/src/Controller/ValidateController.php b/lib/RoadizUserBundle/src/Controller/ValidateController.php new file mode 100644 index 00000000..00fa5851 --- /dev/null +++ b/lib/RoadizUserBundle/src/Controller/ValidateController.php @@ -0,0 +1,28 @@ +eventDispatcher = $eventDispatcher; + } + + public function __invoke(User $data): User + { + $this->eventDispatcher->dispatch(new UserEmailValidated($data)); + return $data; + } +} diff --git a/lib/RoadizUserBundle/src/Controller/ValidationRequestController.php b/lib/RoadizUserBundle/src/Controller/ValidationRequestController.php new file mode 100644 index 00000000..349af1fa --- /dev/null +++ b/lib/RoadizUserBundle/src/Controller/ValidationRequestController.php @@ -0,0 +1,54 @@ +userValidationTokenManager = $userValidationTokenManager; + $this->security = $security; + $this->emailValidatedRoleName = $emailValidatedRoleName; + $this->managerRegistry = $managerRegistry; + } + + public function __invoke(Request $request, User $data): User + { + if (!$this->security->isGranted('ROLE_USER')) { + throw new AccessDeniedHttpException('User must be logged in'); + } + + if ($this->security->getUser()->getUserIdentifier() !== $data->getUserIdentifier()) { + throw new AccessDeniedHttpException('Only current user can request email validation'); + } + + if ($this->security->isGranted($this->emailValidatedRoleName)) { + throw new UnprocessableEntityHttpException('User email is already validated'); + } + + $this->userValidationTokenManager->createForUser($data); + // Validation request must not call WriteListener to let ValidationRequestController persist changes. + $this->managerRegistry->getManager()->flush(); + + return $data; + } +} diff --git a/lib/RoadizUserBundle/src/DependencyInjection/Compiler/DoctrineMigrationCompilerPass.php b/lib/RoadizUserBundle/src/DependencyInjection/Compiler/DoctrineMigrationCompilerPass.php new file mode 100644 index 00000000..a46dfa3b --- /dev/null +++ b/lib/RoadizUserBundle/src/DependencyInjection/Compiler/DoctrineMigrationCompilerPass.php @@ -0,0 +1,59 @@ +hasDefinition('doctrine.migrations.configuration')) { + $configurationDefinition = $container->getDefinition('doctrine.migrations.configuration'); + $ns = 'RZ\Roadiz\UserBundle\Migrations'; + $path = '@RoadizUserBundle/migrations'; + + $path = $this->checkIfBundleRelativePath($path, $container); + $configurationDefinition->addMethodCall('addMigrationsDirectory', [$ns, $path]); + } + } + + private function checkIfBundleRelativePath(string $path, ContainerBuilder $container): string + { + if (isset($path[0]) && $path[0] === '@') { + $pathParts = explode('/', $path); + $bundleName = substr($pathParts[0], 1); + + $bundlePath = $this->getBundlePath($bundleName, $container); + + return $bundlePath . substr($path, strlen('@' . $bundleName)); + } + + return $path; + } + + private function getBundlePath(string $bundleName, ContainerBuilder $container): string + { + $bundleMetadata = $container->getParameter('kernel.bundles_metadata'); + assert(is_array($bundleMetadata)); + + if (! isset($bundleMetadata[$bundleName])) { + throw new RuntimeException( + sprintf( + 'The bundle "%s" has not been registered, available bundles: %s', + $bundleName, + implode(', ', array_keys($bundleMetadata)) + ) + ); + } + + return $bundleMetadata[$bundleName]['path']; + } +} diff --git a/lib/RoadizUserBundle/src/DependencyInjection/Configuration.php b/lib/RoadizUserBundle/src/DependencyInjection/Configuration.php new file mode 100644 index 00000000..3e70e156 --- /dev/null +++ b/lib/RoadizUserBundle/src/DependencyInjection/Configuration.php @@ -0,0 +1,60 @@ +getRootNode(); + $root->addDefaultsIfNotSet() + ->children() + ->scalarNode('password_reset_url') + ->defaultValue('loginResetPage') + ->info( + <<end() + ->scalarNode('user_validation_url') + ->defaultValue('http://example.test/my-account/validate') + ->info( + <<end() + ->integerNode('password_reset_expires_in') + ->defaultValue(600) + ->info( + <<end() + ->integerNode('user_validation_expires_in') + ->defaultValue(3600) + ->info( + <<end() + ->scalarNode('public_user_role_name') + ->defaultValue('ROLE_PUBLIC_USER') + ->end() + ->scalarNode('email_validated_role_name') + ->defaultValue('ROLE_EMAIL_VALIDATED') + ->end(); + return $builder; + } +} diff --git a/lib/RoadizUserBundle/src/DependencyInjection/RoadizUserExtension.php b/lib/RoadizUserBundle/src/DependencyInjection/RoadizUserExtension.php new file mode 100644 index 00000000..8be9303e --- /dev/null +++ b/lib/RoadizUserBundle/src/DependencyInjection/RoadizUserExtension.php @@ -0,0 +1,32 @@ +load('services.yaml'); + + $configuration = new Configuration(); + $config = $this->processConfiguration($configuration, $configs); + + $container->setParameter('roadiz_user.password_reset_url', $config['password_reset_url']); + $container->setParameter('roadiz_user.user_validation_url', $config['user_validation_url']); + $container->setParameter('roadiz_user.password_reset_expires_in', $config['password_reset_expires_in']); + $container->setParameter('roadiz_user.user_validation_expires_in', $config['user_validation_expires_in']); + $container->setParameter('roadiz_user.public_user_role_name', $config['public_user_role_name']); + $container->setParameter('roadiz_user.email_validated_role_name', $config['email_validated_role_name']); + } +} diff --git a/lib/RoadizUserBundle/src/Entity/UserMetadata.php b/lib/RoadizUserBundle/src/Entity/UserMetadata.php new file mode 100644 index 00000000..5a056588 --- /dev/null +++ b/lib/RoadizUserBundle/src/Entity/UserMetadata.php @@ -0,0 +1,79 @@ +id; + } + + /** + * @param int $id + * @return UserMetadata + */ + public function setId(int $id): UserMetadata + { + $this->id = $id; + return $this; + } + + /** + * @return User|null + */ + public function getUser(): ?User + { + return $this->user; + } + + /** + * @param User|null $user + * @return UserMetadata + */ + public function setUser(?User $user): UserMetadata + { + $this->user = $user; + return $this; + } + + /** + * @return array|null + */ + public function getMetadata(): ?array + { + return $this->metadata; + } + + /** + * @param array|null $metadata + * @return UserMetadata + */ + public function setMetadata(?array $metadata): UserMetadata + { + $this->metadata = $metadata; + return $this; + } +} diff --git a/lib/RoadizUserBundle/src/Entity/UserValidationToken.php b/lib/RoadizUserBundle/src/Entity/UserValidationToken.php new file mode 100644 index 00000000..eac0105e --- /dev/null +++ b/lib/RoadizUserBundle/src/Entity/UserValidationToken.php @@ -0,0 +1,106 @@ +id; + } + + /** + * @param int $id + * @return UserValidationToken + */ + public function setId(int $id): UserValidationToken + { + $this->id = $id; + return $this; + } + + + /** + * @return User|null + */ + public function getUser(): ?User + { + return $this->user; + } + + /** + * @param User|null $user + * @return UserValidationToken + */ + public function setUser(?User $user): UserValidationToken + { + $this->user = $user; + return $this; + } + + /** + * @return string + */ + public function getToken(): string + { + return $this->token; + } + + /** + * @param string $token + * @return UserValidationToken + */ + public function setToken(string $token): UserValidationToken + { + $this->token = $token; + return $this; + } + + /** + * @return \DateTime|null + */ + public function getTokenValidUntil(): ?\DateTime + { + return $this->tokenValidUntil; + } + + /** + * @param \DateTime|null $tokenValidUntil + * @return UserValidationToken + */ + public function setTokenValidUntil(?\DateTime $tokenValidUntil): UserValidationToken + { + $this->tokenValidUntil = $tokenValidUntil; + return $this; + } +} diff --git a/lib/RoadizUserBundle/src/Event/UserEmailValidated.php b/lib/RoadizUserBundle/src/Event/UserEmailValidated.php new file mode 100644 index 00000000..202dcbf8 --- /dev/null +++ b/lib/RoadizUserBundle/src/Event/UserEmailValidated.php @@ -0,0 +1,11 @@ +userValidationTokenManager = $userValidationTokenManager; + } + + /** + * @inheritDoc + */ + public static function getSubscribedEvents(): array + { + return [ + UserSignedUp::class => 'onUserSignedUp' + ]; + } + + public function onUserSignedUp(UserSignedUp $event): void + { + $user = $event->getUser(); + $this->userValidationTokenManager->createForUser($user); + } +} diff --git a/lib/RoadizUserBundle/src/Manager/UserMetadataManager.php b/lib/RoadizUserBundle/src/Manager/UserMetadataManager.php new file mode 100644 index 00000000..f2628c63 --- /dev/null +++ b/lib/RoadizUserBundle/src/Manager/UserMetadataManager.php @@ -0,0 +1,35 @@ +managerRegistry = $managerRegistry; + } + + public function getMetadataForUser(User $user): ?UserMetadata + { + return $this->managerRegistry->getRepository(UserMetadata::class)->findOneByUser($user); + } + + public function createMetadataForUser(User $user): UserMetadata + { + $userMetadata = new UserMetadata(); + $userMetadata->setUser($user); + $this->managerRegistry->getManager()->persist($userMetadata); + return $userMetadata; + } +} diff --git a/lib/RoadizUserBundle/src/Manager/UserMetadataManagerInterface.php b/lib/RoadizUserBundle/src/Manager/UserMetadataManagerInterface.php new file mode 100644 index 00000000..20c5dc61 --- /dev/null +++ b/lib/RoadizUserBundle/src/Manager/UserMetadataManagerInterface.php @@ -0,0 +1,14 @@ +managerRegistry = $managerRegistry; + $this->logger = $logger; + $this->userValidationExpiresIn = $userValidationExpiresIn; + $this->emailManager = $emailManager; + $this->userValidationUrl = $userValidationUrl; + $this->settingsBag = $settingsBag; + $this->urlGenerator = $urlGenerator; + $this->translator = $translator; + $this->roleHierarchy = $roleHierarchy; + $this->emailValidatedRoleName = $emailValidatedRoleName; + } + + public function createForUser(User $user): UserValidationToken + { + $existingValidationToken = $this->managerRegistry + ->getRepository(UserValidationToken::class) + ->findOneByUser($user); + $tokenGenerator = new TokenGenerator($this->logger); + + if (null === $existingValidationToken) { + $existingValidationToken = new UserValidationToken(); + $existingValidationToken->setUser($user); + $this->managerRegistry->getManager()->persist($existingValidationToken); + } + + $existingValidationToken->setToken($tokenGenerator->generateToken()); + $existingValidationToken->setTokenValidUntil( + (new \DateTime())->add(new \DateInterval(sprintf('PT%dS', $this->userValidationExpiresIn))) + ); + $this->sendUserValidationEmail($existingValidationToken); + return $existingValidationToken; + } + + public function isUserEmailValidated(UserInterface $user): bool + { + $reachableRoles = $this->roleHierarchy->getReachableRoleNames($user->getRoles()); + return \in_array($this->emailValidatedRoleName, $reachableRoles) || + \in_array('ROLE_SUPER_ADMIN', $reachableRoles) || + \in_array('ROLE_SUPERADMIN', $reachableRoles); + } + + + private function sendUserValidationEmail(UserValidationToken $userValidationToken): void + { + $emailContact = $this->settingsBag->get('support_email_address', null) ?? + $this->settingsBag->get('email_sender', null); + $siteName = $this->settingsBag->get('site_name'); + + /* + * Support routes name as well as hard-coded URLs + */ + try { + $validationLink = $this->urlGenerator->generate( + $this->userValidationUrl, + [ + 'token' => $userValidationToken->getToken(), + '_locale' => $userValidationToken->getUser()?->getLocale(), + ], + UrlGeneratorInterface::ABSOLUTE_URL + ); + } catch (RouteNotFoundException $exception) { + $validationLink = $this->userValidationUrl . '?' . http_build_query( + [ + 'token' => $userValidationToken->getToken(), + '_locale' => $userValidationToken->getUser()?->getLocale(), + ] + ); + } + + $this->emailManager->setAssignation( + [ + 'validationLink' => $validationLink, + 'user' => $userValidationToken->getUser(), + 'site' => $siteName, + 'mailContact' => $emailContact, + ] + ); + $this->emailManager->setEmailTemplate('@RoadizUser/email/users/validate_email.html.twig'); + $this->emailManager->setEmailPlainTextTemplate('@RoadizUser/email/users/validate_email.txt.twig'); + $this->emailManager->setSubject( + $this->translator->trans( + 'validate_email.subject' + ) + ); + $this->emailManager->setReceiver($userValidationToken->getUser()->getEmail()); + $this->emailManager->setSender(new Address($emailContact, $siteName ?? '')); + $this->emailManager->send(); + } +} diff --git a/lib/RoadizUserBundle/src/Manager/UserValidationTokenManagerInterface.php b/lib/RoadizUserBundle/src/Manager/UserValidationTokenManagerInterface.php new file mode 100644 index 00000000..3bfaaf79 --- /dev/null +++ b/lib/RoadizUserBundle/src/Manager/UserValidationTokenManagerInterface.php @@ -0,0 +1,15 @@ +createQueryBuilder('t'); + $qb->delete() + ->andWhere($qb->expr()->lt('t.tokenValidUntil', ':now')) + ->setParameter(':now', new \DateTime()); + + return $qb->getQuery()->getResult() ?? 0; + } + + public function findOneByValidToken(string $token): ?UserValidationToken + { + $qb = $this->createQueryBuilder('t'); + return $qb->andWhere($qb->expr()->eq('t.token', ':token')) + ->andWhere($qb->expr()->gte('t.tokenValidUntil', ':now')) + ->setParameter('token', $token) + ->setParameter('now', new \DateTime()) + ->getQuery() + ->getOneOrNullResult(); + } +} diff --git a/lib/RoadizUserBundle/src/RoadizUserBundle.php b/lib/RoadizUserBundle/src/RoadizUserBundle.php new file mode 100644 index 00000000..1ee70490 --- /dev/null +++ b/lib/RoadizUserBundle/src/RoadizUserBundle.php @@ -0,0 +1,24 @@ +addCompilerPass(new DoctrineMigrationCompilerPass()); + } +} diff --git a/lib/RoadizUserBundle/templates/email/users/reset_password_email.html.twig b/lib/RoadizUserBundle/templates/email/users/reset_password_email.html.twig new file mode 100644 index 00000000..b5202118 --- /dev/null +++ b/lib/RoadizUserBundle/templates/email/users/reset_password_email.html.twig @@ -0,0 +1,63 @@ +{% extends '@RoadizCore/email/base_email.html.twig' %} + +{% block title %}{% trans %}reset.password.request{% endtrans %}{% endblock %} + +{% block content_table %} + + + + + + + +
+ {% block content_title %} +

{% trans %}reset.password.request{% endtrans %}

+ {% endblock %} +
+ + + + + + + + + + +
+ {% block content_subtitle %} +

{{ 'you.asked.for.a.password.reset.on.%site%'|trans({'%site%':site})|escape }}

+ {% endblock %} +
+

{% trans %}you.need.to.choose.a.new.password.using.following.link{% endtrans %}

+

+ +

+ {% block content_disclaimer %} +

+ {% trans %}if.you.didnt.request.this.password.reset.ignore.this.email{% endtrans %} +

+ {% endblock %} +
+

{% trans %}as.a.reminder.here.are.your.credentials{% endtrans %}

+ + {% if user.email == user.userIdentifier %} + + + + + {% else %} + + + + + + + + + {% endif %} +
{% trans %}email{% endtrans %}{{ user.email|escape }}
{% trans %}username{% endtrans %}{{ user.userIdentifier|escape }}
{% trans %}email{% endtrans %}{{ user.email|escape }}
+
+
+{% endblock %} diff --git a/lib/RoadizUserBundle/templates/email/users/reset_password_email.txt.twig b/lib/RoadizUserBundle/templates/email/users/reset_password_email.txt.twig new file mode 100644 index 00000000..063c4c10 --- /dev/null +++ b/lib/RoadizUserBundle/templates/email/users/reset_password_email.txt.twig @@ -0,0 +1,26 @@ +{% extends '@RoadizCore/email/base_email.txt.twig' %} + +{% block title %}{% trans %}reset.password.request{% endtrans %}{% endblock %} + +{% block content_table %} +{% block content_subtitle %}{{ 'you.asked.for.a.password.reset.on.%site%'|trans({'%site%':site}) }}{% endblock %} + +{% trans %}you.need.to.choose.a.new.password.using.following.link{% endtrans %} + +--- +{{ resetLink|raw }} +--- + +{% block content_disclaimer %} +{% trans %}if.you.didnt.request.this.password.reset.ignore.this.email{% endtrans %} +{% endblock %} + +{% trans %}as.a.reminder.here.are.your.credentials{% endtrans %} + +{% if user.email == user.userIdentifier %} +{% trans %}email{% endtrans %}: {{ user.email }} +{% else %} +{% trans %}username{% endtrans %}: {{ user.userIdentifier }} +{% trans %}email{% endtrans %}: {{ user.email }} +{% endif %} +{% endblock %} diff --git a/lib/RoadizUserBundle/templates/email/users/validate_email.html.twig b/lib/RoadizUserBundle/templates/email/users/validate_email.html.twig new file mode 100644 index 00000000..c1855e52 --- /dev/null +++ b/lib/RoadizUserBundle/templates/email/users/validate_email.html.twig @@ -0,0 +1,59 @@ +{% extends '@RoadizCore/email/base_email.html.twig' %} + +{% block title %}{% trans %}validate_email.subject{% endtrans %}{% endblock %} + +{% block content_table %} + + + + + + + +
+ {% block content_title %} +

{% trans %}validate_email.subject{% endtrans %}

+ {% endblock %} +
+ + + + + + + + + + +
+ {% block content_subtitle %} +

{{ 'validate_email.subtitle'|trans({'%site%':site})|escape }}

+ {% endblock %} +
+

{% trans %}validate_email.content{% endtrans %}

+

+ +

+ {% block content_disclaimer %}{% endblock %} +
+

{% trans %}as.a.reminder.here.are.your.credentials{% endtrans %}

+ + {% if user.email == user.userIdentifier %} + + + + + {% else %} + + + + + + + + + {% endif %} +
{% trans %}email{% endtrans %}{{ user.email|escape }}
{% trans %}username{% endtrans %}{{ user.userIdentifier|escape }}
{% trans %}email{% endtrans %}{{ user.email|escape }}
+
+
+{% endblock %} diff --git a/lib/RoadizUserBundle/templates/email/users/validate_email.txt.twig b/lib/RoadizUserBundle/templates/email/users/validate_email.txt.twig new file mode 100644 index 00000000..1780dbd7 --- /dev/null +++ b/lib/RoadizUserBundle/templates/email/users/validate_email.txt.twig @@ -0,0 +1,24 @@ +{% extends '@RoadizCore/email/base_email.txt.twig' %} + +{% block title %}{% trans %}validate_email.subject{% endtrans %}{% endblock %} + +{% block content_table %} +{% block content_subtitle %}{{ 'validate_email.subtitle'|trans({'%site%':site}) }}{% endblock %} + +{% trans %}validate_email.content{% endtrans %} + +--- +{{ validationLink|raw }} +--- + +{% block content_disclaimer %}{% endblock %} + +{% trans %}as.a.reminder.here.are.your.credentials{% endtrans %} + +{% if user.email == user.userIdentifier %} +{% trans %}email{% endtrans %}: {{ user.email }} +{% else %} +{% trans %}username{% endtrans %}: {{ user.userIdentifier }} +{% trans %}email{% endtrans %}: {{ user.email }} +{% endif %} +{% endblock %} diff --git a/lib/RoadizUserBundle/translations/email/messages.ar.xlf b/lib/RoadizUserBundle/translations/email/messages.ar.xlf new file mode 100644 index 00000000..6469320f --- /dev/null +++ b/lib/RoadizUserBundle/translations/email/messages.ar.xlf @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/lib/RoadizUserBundle/translations/email/messages.de.xlf b/lib/RoadizUserBundle/translations/email/messages.de.xlf new file mode 100644 index 00000000..7177f9ec --- /dev/null +++ b/lib/RoadizUserBundle/translations/email/messages.de.xlf @@ -0,0 +1,27 @@ + + + + + + validate_email.subject + Bestätigen Sie Ihre Konto-E-Mail-Adresse + + + validate_email.subtitle + Willkommen auf %site%! + + + validate_email.content + Bitte klicken Sie auf den unten stehenden Link, um Ihre E-Mail-Adresse zu verifizieren. + + + validate_email.btn_label + Bestätigen Sie meine E-Mail-Adresse + + + as.a.reminder.here.are.your.credentials + Zur Erinnerung: Hier sind Ihre Anmeldedaten + + + + diff --git a/lib/RoadizUserBundle/translations/email/messages.en.xlf b/lib/RoadizUserBundle/translations/email/messages.en.xlf new file mode 100644 index 00000000..ba8e4d55 --- /dev/null +++ b/lib/RoadizUserBundle/translations/email/messages.en.xlf @@ -0,0 +1,27 @@ + + + + + + validate_email.subject + Validate your account email address + + + validate_email.subtitle + Welcome on “%site%”! + + + validate_email.content + Please, click on link below to verify your account email address. + + + validate_email.btn_label + Validate my email address + + + as.a.reminder.here.are.your.credentials + As a reminder here are your credentials + + + + diff --git a/lib/RoadizUserBundle/translations/email/messages.es.xlf b/lib/RoadizUserBundle/translations/email/messages.es.xlf new file mode 100644 index 00000000..64e4a946 --- /dev/null +++ b/lib/RoadizUserBundle/translations/email/messages.es.xlf @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/lib/RoadizUserBundle/translations/email/messages.fr.xlf b/lib/RoadizUserBundle/translations/email/messages.fr.xlf new file mode 100644 index 00000000..91c7898f --- /dev/null +++ b/lib/RoadizUserBundle/translations/email/messages.fr.xlf @@ -0,0 +1,27 @@ + + + + + + validate_email.subject + Valider l'email lié à votre compte + + + validate_email.subtitle + Bienvenue sur “%site%” ! + + + validate_email.content + Veuillez cliquer sur le lien ci-dessous pour vérifier l'adresse email liée à votre compte. + + + validate_email.btn_label + Valider mon adresse email + + + as.a.reminder.here.are.your.credentials + Pour rappel voici vos informations de connexion + + + + diff --git a/lib/RoadizUserBundle/translations/email/messages.id.xlf b/lib/RoadizUserBundle/translations/email/messages.id.xlf new file mode 100644 index 00000000..b168a64f --- /dev/null +++ b/lib/RoadizUserBundle/translations/email/messages.id.xlf @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/lib/RoadizUserBundle/translations/email/messages.it.xlf b/lib/RoadizUserBundle/translations/email/messages.it.xlf new file mode 100644 index 00000000..12dc0919 --- /dev/null +++ b/lib/RoadizUserBundle/translations/email/messages.it.xlf @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/lib/RoadizUserBundle/translations/email/messages.ru.xlf b/lib/RoadizUserBundle/translations/email/messages.ru.xlf new file mode 100644 index 00000000..cb13fdf6 --- /dev/null +++ b/lib/RoadizUserBundle/translations/email/messages.ru.xlf @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/lib/RoadizUserBundle/translations/email/messages.sr.xlf b/lib/RoadizUserBundle/translations/email/messages.sr.xlf new file mode 100644 index 00000000..c57636da --- /dev/null +++ b/lib/RoadizUserBundle/translations/email/messages.sr.xlf @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/lib/RoadizUserBundle/translations/email/messages.tr.xlf b/lib/RoadizUserBundle/translations/email/messages.tr.xlf new file mode 100644 index 00000000..c0179fe0 --- /dev/null +++ b/lib/RoadizUserBundle/translations/email/messages.tr.xlf @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/lib/RoadizUserBundle/translations/email/messages.uk.xlf b/lib/RoadizUserBundle/translations/email/messages.uk.xlf new file mode 100644 index 00000000..6c458359 --- /dev/null +++ b/lib/RoadizUserBundle/translations/email/messages.uk.xlf @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/lib/RoadizUserBundle/translations/email/messages.xlf b/lib/RoadizUserBundle/translations/email/messages.xlf new file mode 100644 index 00000000..0837cbb6 --- /dev/null +++ b/lib/RoadizUserBundle/translations/email/messages.xlf @@ -0,0 +1,27 @@ + + + + + + validate_email.subject + + + + validate_email.subtitle + + + + validate_email.content + + + + validate_email.btn_label + + + + as.a.reminder.here.are.your.credentials + + + + + diff --git a/lib/RoadizUserBundle/translations/email/messages.zh.xlf b/lib/RoadizUserBundle/translations/email/messages.zh.xlf new file mode 100644 index 00000000..a809b6d5 --- /dev/null +++ b/lib/RoadizUserBundle/translations/email/messages.zh.xlf @@ -0,0 +1,19 @@ + + + + + + validate_email.subject + 验证您的账户电子邮件地址 + + + validate_email.subtitle + 欢迎来到 “%site%”! + + + validate_email.btn_label + 确认我的电子邮件地址 + + + + diff --git a/lib/Rozier b/lib/Rozier deleted file mode 160000 index 76d19275..00000000 --- a/lib/Rozier +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 76d19275ae98358145a91cb76b0a3ba7b32b1923 diff --git a/lib/Rozier/.editorconfig b/lib/Rozier/.editorconfig new file mode 100644 index 00000000..c3ccb818 --- /dev/null +++ b/lib/Rozier/.editorconfig @@ -0,0 +1,17 @@ +# Roadiz editor config for contributors +# http://editorconfig.org/ +root = true + +[*] +indent_style = space +indent_size = 4 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.md] +trim_trailing_whitespace = false + +[Makefile] +indent_style = tab diff --git a/lib/Rozier/.github/workflows/run-test.yml b/lib/Rozier/.github/workflows/run-test.yml new file mode 100644 index 00000000..3b17d56e --- /dev/null +++ b/lib/Rozier/.github/workflows/run-test.yml @@ -0,0 +1,41 @@ +name: Static analysis and code style + +on: + push: + branches: + - develop + - 'release/**' + - 'hotfix/**' + tags: ['**'] + pull_request: + types: + - opened + - reopened + - synchronize + - ready_for_review + +jobs: + static-analysis-tests: + runs-on: ubuntu-latest + strategy: + matrix: + php-version: ['8.0', '8.1'] + steps: + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + - uses: actions/checkout@v3 + - name: Cache Composer packages + id: composer-cache + uses: actions/cache@v3 + with: + path: vendor + key: ${{ runner.os }}-php-${{ matrix.php-version }}-${{ hashFiles('**/composer.lock') }} + restore-keys: | + ${{ runner.os }}-php-${{ matrix.php-version }}- + - name: Install Dependencies + run: composer install --no-scripts --no-ansi --no-interaction --no-progress + - name: Run PHP Code Sniffer + run: vendor/bin/phpcs -p ./src + - name: Run PHPStan + run: vendor/bin/phpstan analyse --no-progress -c phpstan.neon diff --git a/lib/Rozier/.gitignore b/lib/Rozier/.gitignore new file mode 100644 index 00000000..ea8d6aa9 --- /dev/null +++ b/lib/Rozier/.gitignore @@ -0,0 +1,121 @@ +# +# Roadiz +# +/.env +/.data +/*.sql +/dev.php +/install.php +/clear_cache.php +/pimple.json +/assets +project_env.sh + +# Ignore Google webmaster tool verification +/google*.html + +# PHPCS report +/report.txt +/.php_cs.cache +/.phpcs-cache + +# Cloverage build folder +/build + +# Enable favicon customisation +/favicon.ico + +# Apache files +.htpasswd +.htaccess + +# Some old css tricks for IE +*.htc + +# Created by https://www.gitignore.io + +### SublimeText ### +# cache files for sublime text +*.tmlanguage.cache +*.tmPreferences.cache +*.stTheme.cache + +# workspace files are user-specific +*.sublime-workspace + +# project files should be checked into the repository, unless a significant +# proportion of contributors will probably not be using SublimeText +# *.sublime-project + +# sftp configuration file +sftp-config.json + + +### Composer ### +composer.phar +/vendor + +# Commit your application's lock file http://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file +# You may choose to ignore a library lock file http://getcomposer.org/doc/02-libraries.md#lock-file +composer.lock + +### Node ### +# Logs +logs +*.log + +# Except for logs folder +!/logs + +# Runtime data +pids +*.pid +*.seed + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release + +# Dependency directory +# https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git +node_modules + +### Vagrant ### +/Vagrantfile +.vagrant/ + +### Docker ### +/docker-compose.dev.yml +/docker-compose.local.yml + +### grunt ### +# Grunt usually compiles files inside this directory +dist/ + +# Grunt usually preprocesses files such as coffeescript, compass... inside the .tmp directory +.tmp/ +testDir/ + +## PHPStorm +.idea + +## XDebug profile folder +/.xdebug + +### Bower ### +bower_components +.bower-cache +.bower-registry +.bower-tmp +/phpunit.xml diff --git a/lib/Rozier/.travis.yml b/lib/Rozier/.travis.yml new file mode 100644 index 00000000..5346b623 --- /dev/null +++ b/lib/Rozier/.travis.yml @@ -0,0 +1,75 @@ +node_js: + - "14" +php: + - 8.0 + - 8.1 + - nightly +dist: bionic +stages: + - "PHP lint tests" + - "Backoffice assets tests" +branches: + except: + - l10n_develop + +jobs: + allow_failures: + - php: nightly + + include: + - stage: "Backoffice assets tests" + language: node_js + node_js: "14" + script: sh .travis/backoffice_assets.sh + + - stage: "PHP lint tests" + language: php + sudo: required + services: + - mysql + env: + - DB=mysql + - MYSQL_VERSION=5.7 + - MYSQL_PASSWORD= + php: 7.4 + install: sh .travis/composer_install.sh + script: sh .travis/php_lint.sh + - stage: "PHP lint tests" + language: php + sudo: required + services: + - mysql + env: + - DB=mysql + - MYSQL_VERSION=5.7 + - MYSQL_PASSWORD= + php: 8.0 + install: sh .travis/composer_install.sh + script: sh .travis/php_lint.sh + - stage: "PHP lint tests" + language: php + sudo: required + services: + - mysql + env: + - DB=mysql + - MYSQL_VERSION=5.7 + - MYSQL_PASSWORD= + php: 8.1 + install: sh .travis/composer_install.sh + script: sh .travis/php_lint.sh + - stage: "PHP lint tests" + language: php + sudo: required + services: + - mysql + env: + - DB=mysql + - MYSQL_VERSION=5.7 + - MYSQL_PASSWORD= + php: nightly + install: sh .travis/composer_install.sh + script: sh .travis/php_lint.sh + + + diff --git a/lib/Rozier/.travis/backoffice_assets.sh b/lib/Rozier/.travis/backoffice_assets.sh new file mode 100644 index 00000000..92ba88c8 --- /dev/null +++ b/lib/Rozier/.travis/backoffice_assets.sh @@ -0,0 +1,5 @@ +#!/bin/sh -x +cd src || exit 1; +yarn install --pure-lockfile +yarn run install +yarn run build diff --git a/lib/Rozier/.travis/composer_install.sh b/lib/Rozier/.travis/composer_install.sh new file mode 100644 index 00000000..628aaebb --- /dev/null +++ b/lib/Rozier/.travis/composer_install.sh @@ -0,0 +1,4 @@ +#!/bin/sh -x +phpenv config-rm xdebug.ini; +curl -s http://getcomposer.org/installer | php; +php composer.phar install --dev --no-interaction; diff --git a/lib/Rozier/.travis/php_lint.sh b/lib/Rozier/.travis/php_lint.sh new file mode 100644 index 00000000..624af292 --- /dev/null +++ b/lib/Rozier/.travis/php_lint.sh @@ -0,0 +1,5 @@ +#!/bin/sh -x +vendor/bin/phpcs --report=full --report-file=./report.txt -p ./ || exit 1; +vendor/bin/phpstan analyse -c phpstan.neon || exit 1; +#vendor/bin/console lint:twig || exit 1; +#vendor/bin/console lint:twig src/Resources/views || exit 1; diff --git a/lib/Rozier/CHANGELOG.md b/lib/Rozier/CHANGELOG.md new file mode 100644 index 00000000..6d4ae884 --- /dev/null +++ b/lib/Rozier/CHANGELOG.md @@ -0,0 +1,195 @@ +## 2.0.18 (2023-01-27) + + +### Bug Fixes + +* Fixed DocumentPreviewListItem.vue with Twig filters inside ([8de6fd0](https://github.com/roadiz/rozier/commit/8de6fd08d06f26a07cb16c218784b2644dc4cf64)) + +## 2.0.17 (2023-01-02) + +### Bug Fixes + +* Send preview JWT after nodeSource AJAX edition ([a9adee9](https://github.com/roadiz/rozier/commit/a9adee9a6dd057edd9cb172943e9fada25768ff4)) + +## 2.0.16 (2022-11-29) + +### Bug Fixes + +* Do not use Solr Search Engine if service is NULL ([3bb599f](https://github.com/roadiz/rozier/commit/3bb599ffe4cde56fe0057c7cfbcad0e7826bd76d)) + +## 2.0.15 (2022-10-17) + +### Features + +* Allow document creation date edition if security allows it (Role: `ROLE_ACCESS_DOCUMENTS_CREATION_DATE`) ([8b056d7](https://github.com/roadiz/rozier/commit/8b056d767c5d92ef6bcfd3bd27c16d57dc8dd1d5)) + +## 2.0.14 (2022-10-07) + +### Bug Fixes + +* Allow dev-develop dependencies ([7ccf628](https://github.com/roadiz/rozier/commit/7ccf628afb6af792eb4b4943cbbbdc78d7e833e9)) + +## 2.0.13 (2022-10-03) + +### Features + +* Rebuilt all styles ([0b7a97c](https://github.com/roadiz/rozier/commit/0b7a97c5ef0f3b29710ee7b6580ccd5ce31eca9e)) +* Switched to CSS grid layout for each admin panels ([66aad20](https://github.com/roadiz/rozier/commit/66aad20d3904de756f0d56b689164b00079ba845)) + +## 2.0.12 (2022-09-30) + +### Features + +* **Translations:** Let the user choose source and destination translations ([9ceacf5](https://github.com/roadiz/rozier/commit/9ceacf5799ef34da4a15cc70d81d9438c3c8ef57)) +* **Translations:** Moved TranslateNodeType and TranslateController to RozierBundle ([399e028](https://github.com/roadiz/rozier/commit/399e028a05811199842287968b862c4299c5c663)) + +## 2.0.11 (2022-09-28) + +### Features + +* **Documents:** Dispatch DocumentFileUpdated event after DB flush ([92a341f](https://github.com/roadiz/rozier/commit/92a341f4fdfb2528ec3b3ce9ddfd6d958f337d6b)) + +## 2.0.10 (2022-09-28) + +### Bug Fixes + +* **Drawer:** Fixed drawer description not included in NodeSource edition tabs ([9f0a563](https://github.com/roadiz/rozier/commit/9f0a5631860f26c42984915ef0cbbbd87ad36376)) + +## 2.0.9 (2022-09-16) + +### Bug Fixes + +* Remove dead-code and moved most of the constraints out of Forms in favor of Entity annotations (CoreBundle) ([fa49e53](https://github.com/roadiz/rozier/commit/fa49e5349524cc22e2d5db78ca2e419e71e1ab95)) + +## 2.0.8 (2022-09-16) + +### Bug Fixes + +* Added empty_data on non-nullable string fields, set nodeName on AddNodeType ([bb027df](https://github.com/roadiz/rozier/commit/bb027dfe934a3ad3711eb17ccbae34aaa07fab4a)) + +## 2.0.7 (2022-09-01) + +### Bug Fixes + +* Trans-typing SHOULD be executed in one single SQL transaction ([9901f6b](https://github.com/roadiz/rozier/commit/9901f6b38a470c574ff0c6efd7dfca0899e95e51)) + +## 2.0.6 (2022-09-01) + +### Bug Fixes + +* Deletions buttons must be hidden for locked tags and folders ([5702bd1](https://github.com/roadiz/rozier/commit/5702bd1fac93cc119938f1085d7e4c16facef60c)) + +## 2.0.5 (2022-09-01) + +* Translations updates + +### Bug Fixes + +* Fixed cssAction queries for tags and folders ([49679bf](https://github.com/roadiz/rozier/commit/49679bf70fbc8514eaad6a21ce6ff098da602bd7)) + +## 2.0.4 (2022-09-01) + +### Features + +* Added Folder `locked` and `color` form type fields and tree layout changes ([27beea1](https://github.com/roadiz/rozier/commit/27beea19d79eeaa2383dd12ca27651f806049352)) +* New `ConfigurableExplorerItem` to refactor entity explorer with custom doctrine entities ([64ef927](https://github.com/roadiz/rozier/commit/64ef927dbcdbfbf7fa331fd2889621358aa19f50)) + +## 2.0.3 (2022-07-05) + +### Bug Fixes + +* Allow dev-develop versions for Roadiz bundles ([0badd5e](https://github.com/roadiz/rozier/commit/0badd5ef502aaa20ecdc88227be3a50a95571ad1)) + +## 2.0.2 (2022-07-01) + +### Bug Fixes + +* Misuse of InputBag filter args ([73469a1](https://github.com/roadiz/rozier/commit/73469a1290d97f7791ecad3d16b2b0faf6156d19)) + +## 2.0.1 (2022-07-01) + +### Bug Fixes + +* InputBag query all and filter on array ([e7a3ece](https://github.com/roadiz/rozier/commit/e7a3ece33db836b630c8a1bfbd517e57cf3e4c55)) + +## 2.0.0 (2022-06-30) + +### ⚠ BREAKING CHANGES + +* Theme requires Roadiz v2 +* Changed `Rozier` twig namespace to `RoadizRozier` + +### Features + +* Added post flush event dispatches in AbstractAdminController ([97934f7](https://github.com/roadiz/rozier/commit/97934f73b5f8fb47ad8dc88f5ec7e3e1192756a1)) +* Allow multiple events to be dispatched from AbstractAdminController ([6fa425e](https://github.com/roadiz/rozier/commit/6fa425e2bb2c25928b4f30c3534af1be314f9b20)) +* Prefix all abstractAdminController template folder ([516e4db](https://github.com/roadiz/rozier/commit/516e4db56631e616c0b74bfe48e031695adb6815)) +* Prefix all form themes templates ([16b8960](https://github.com/roadiz/rozier/commit/16b89602b639831bfc00a4e0246d38593172a9da)) +* Prefix all templates to be overrideable ([c6fcfeb](https://github.com/roadiz/rozier/commit/c6fcfeb0b640d39ad82d7ff8f92bf5ad1d160b57)) +* Remove dead code for Roadiz v2: routings, overriden classes and Pimple services ([d23eb52](https://github.com/roadiz/rozier/commit/d23eb527300643ead9c7d75e118733f0512e2f99)) +* Template namespace ([0372aa9](https://github.com/roadiz/rozier/commit/0372aa97a8d736408e0f5b27f9f65f23f2a9e59b)) +* Use PersistableInterface instead of AbstractEntity ([568f387](https://github.com/roadiz/rozier/commit/568f3874ea18bdd62a14cb38546618fd8b787666)) + +### Bug Fixes + +* Check if search result is NodesSources ([99e1fe2](https://github.com/roadiz/rozier/commit/99e1fe2161e97a5c8c7822c4d7cea8554a21fc6c)) +* Export document with their folder to avoid overriding same filename documents ([97ce14c](https://github.com/roadiz/rozier/commit/97ce14c7b699323129b95acb6c4fcc27c11446f6)) +* Missing template namespace ([ba27c5f](https://github.com/roadiz/rozier/commit/ba27c5f2321514612fae972e414003927c5ae5fe)) +* Missing translations ([d53d60b](https://github.com/roadiz/rozier/commit/d53d60b6f10e0d386bf24d0be14f525f507c959d)) +* NodeSourceJoinType MUST always be multiple as data is submitted as array ([bc5282b](https://github.com/roadiz/rozier/commit/bc5282bb6f78fc6a71116b6038e8f010f78fd4c2)) +* Use Request Query all method instead of get for arrays ([2414911](https://github.com/roadiz/rozier/commit/24149116d6fdfecf97046b76a9767d34d499d14a)) + +## 1.7.16 (2022-06-22) + +### Bug Fixes + +* Display always all translations for backend users, not only available ones ([fd4f44d](https://github.com/roadiz/rozier/commit/fd4f44d6c830887d31233aee5bbacb532cf2ceec)) + +## 1.7.14 (2022-06-02) + +### Features + +* Allow multiple email comma-separated in CustomFormType ([a98fa8e](https://github.com/roadiz/rozier/commit/a98fa8ee6b7d314175aa04b673371ccf79734bcb)) + +## 1.7.13 (2022-06-02) + +### Bug Fixes + +* Fixed input[type=datetime-local] style ([b04d426](https://github.com/roadiz/rozier/commit/b04d4269cf4f939da4440e0142ce7cadc054ac59)) + +## 1.7.12 (2022-05-25) + +### Bug Fixes + +* Non required CustomForm closeDate form ([5a58ee8](https://github.com/roadiz/rozier/commit/5a58ee869c1ad870cbe1befa3c35df86e3b81a8f)) + +## 1.7.11 (2022-04-12) + +### Bug Fixes + +* Use :not(:placeholder-shown):invalid instead of :invalid ([7f8bace](https://github.com/roadiz/rozier/commit/7f8bacec4064a5c7f2cd5b66c1f9b79a7841d389)) + +## 1.7.10 (2022-04-07) + +### Bug Fixes + +* Fixed nodetree style when nodes has tags ([badddb1](https://github.com/roadiz/rozier/commit/badddb1476a47253c8bd6c5e79260ae63ab9e4c4)) + +## 1.7.9 (2022-03-29) + +### Features + +* Styled invalid state for inputs ([956d12a](https://github.com/roadiz/rozier/commit/956d12a32f95aef4afd3125d79473f5ee57b9cdb)) + +## 1.7.8 (2022-03-29) + +### Bug Fixes + +* Remove user group form was not handled ([efc55aa](https://github.com/roadiz/rozier/commit/efc55aa4725def7a1c7ae377bfbd8936f6c9a1bb)) + +## 1.7.7 (2022-03-24) + +### Features + +* Dispatch UserJoinedGroupEvent and UserLeavedGroupEvent ([e895e3c](https://github.com/roadiz/rozier/commit/e895e3cc827f46704b5e0c420d9c8d1706484510)) + diff --git a/lib/Rozier/LICENSE.md b/lib/Rozier/LICENSE.md new file mode 100644 index 00000000..747e48b2 --- /dev/null +++ b/lib/Rozier/LICENSE.md @@ -0,0 +1,9 @@ +The MIT License (MIT) + +Copyright © 2023 Ambroise Maupate, Julien Blanchet + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/lib/Rozier/Makefile b/lib/Rozier/Makefile new file mode 100644 index 00000000..865ab9a1 --- /dev/null +++ b/lib/Rozier/Makefile @@ -0,0 +1,5 @@ + +test: + php -d "memory_limit=-1" vendor/bin/phpcs --report=full --report-file=./report.txt -p ./ + php -d "memory_limit=-1" vendor/bin/phpstan analyse -c phpstan.neon + diff --git a/lib/Rozier/README.md b/lib/Rozier/README.md new file mode 100644 index 00000000..64d299f6 --- /dev/null +++ b/lib/Rozier/README.md @@ -0,0 +1,17 @@ +# Rozier: Roadiz back-office theme + +![Run test status](https://github.com/roadiz/rozier/actions/workflows/run-test.yml/badge.svg?branch=develop) + +## Contribute + +To enhance Rozier backend theme you must install Grunt and Bower: + +```shell +cd src +yarn install +# Launch Grunt to generate prod files +yarn build +# Or… launch watch grunt when you’re +# working on LESS and JS files. +yarn dev +``` diff --git a/lib/Rozier/composer.json b/lib/Rozier/composer.json new file mode 100644 index 00000000..59869004 --- /dev/null +++ b/lib/Rozier/composer.json @@ -0,0 +1,97 @@ +{ + "name": "roadiz/rozier", + "description": "Roadiz CMS backoffice theme", + "license": "MIT", + "type": "library", + "keywords": [ + "cms", + "backoffice", + "rezo zero" + ], + "authors": [ + { + "name": "Ambroise Maupate", + "email": "ambroise@roadiz.io", + "homepage": "https://www.roadiz.io", + "role": "Lead developer" + }, + { + "name": "Julien Blanchet", + "email": "julien@roadiz.io", + "homepage": "https://www.roadiz.io", + "role": "AD, integrator" + }, + { + "name": "Adrien Scholaert", + "email": "contact@adrienscholaert.fr", + "homepage": "http://adrienscholaert.fr", + "role": "Frontend developer" + } + ], + "require": { + "php": ">=8.0", + "ext-zip": "*", + "doctrine/orm": "^2.14.1", + "guzzlehttp/guzzle": "^7.2.0", + "jms/serializer": "^3.9.0", + "league/flysystem": "^3.0", + "pimple/pimple": "^3.3.1", + "roadiz/core-bundle": "2.1.x-dev", + "roadiz/compat-bundle": "2.1.x-dev", + "roadiz/rozier-bundle": "2.1.x-dev", + "roadiz/doc-generator": "2.1.x-dev", + "roadiz/documents": "2.1.x-dev", + "roadiz/dts-generator": "2.1.x-dev", + "roadiz/models": "2.1.x-dev", + "roadiz/nodetype-contracts": "~1.1.2", + "roadiz/openid": "2.1.x-dev", + "symfony/asset": "5.4.*", + "symfony/filesystem": "5.4.*", + "symfony/form": "5.4.*", + "symfony/http-foundation": "5.4.*", + "symfony/http-kernel": "5.4.*", + "symfony/routing": "5.4.*", + "symfony/security-core": "5.4.*", + "symfony/security-http": "5.4.*", + "symfony/security-csrf": "5.4.*", + "symfony/translation": "5.4.*", + "symfony/validator": "5.4.*", + "symfony/workflow": "5.4.*", + "symfony/yaml": "5.4.*", + "twig/twig": "^3.1" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.4", + "phpstan/phpstan": "^1.5.3", + "phpstan/phpstan-doctrine": "^1.3", + "squizlabs/php_codesniffer": "^3.5", + "roadiz/entity-generator": "2.1.x-dev" + }, + "autoload": { + "psr-4": { + "Themes\\Rozier\\": "src/" + } + }, + "scripts": { + "test": [ + "php -d \"memory_limit=-1\" bin/phpcs --report=full --report-file=./report.txt -p ./", + "php -d \"memory_limit=-1\" bin/phpstan analyse -c phpstan.neon", + "php -d \"memory_limit=-1\" bin/roadiz lint:twig", + "php -d \"memory_limit=-1\" bin/roadiz lint:twig src/Resources/views" + ] + }, + "config": { + "allow-plugins": { + "composer/package-versions-deprecated": true, + "symfony/flex": false, + "symfony/runtime": false, + "php-http/discovery": false + } + }, + "extra": { + "branch-alias": { + "dev-main": "2.0.x-dev", + "dev-develop": "2.1.x-dev" + } + } +} diff --git a/lib/Rozier/crowdin.yml b/lib/Rozier/crowdin.yml new file mode 100644 index 00000000..50e504da --- /dev/null +++ b/lib/Rozier/crowdin.yml @@ -0,0 +1,8 @@ +files: + - source: /**/*.xlf + ignore: + - /**/*.%two_letters_code%.xlf + - /**/*.%locale_with_underscore%.xlf + - /**/*.%locale%.xlf + - /**/*.sr_Cyrl.xlf + translation: /%original_path%/%file_name%.%two_letters_code%.%file_extension% diff --git a/lib/Rozier/phpcs.xml.dist b/lib/Rozier/phpcs.xml.dist new file mode 100644 index 00000000..8e43eb01 --- /dev/null +++ b/lib/Rozier/phpcs.xml.dist @@ -0,0 +1,26 @@ + + + + + + + + + + + + ./ + *.js + *.vue + */Resources/app + */node_modules + */.AppleDouble + */vendor + */cache + */gen-src + */tests + */bin + */themes + .data/* + diff --git a/lib/Rozier/phpstan.neon b/lib/Rozier/phpstan.neon new file mode 100644 index 00000000..eed7061a --- /dev/null +++ b/lib/Rozier/phpstan.neon @@ -0,0 +1,36 @@ +parameters: + level: 6 + paths: + - src + excludePaths: + - */node_modules/* + - */bower_components/* + - */static/* + doctrine: + repositoryClass: RZ\Roadiz\Core\Repositories\EntityRepository + ignoreErrors: + - '#Call to an undefined method RZ\\Roadiz\\CoreBundle\\Repository#' + - '#Call to an undefined method RZ\\Roadiz\\UserBundle\\Repository#' + - '#Call to an undefined method Doctrine\\Persistence\\ObjectRepository#' + - '#Call to an undefined method Doctrine\\Persistence\\ObjectManager#' + - '#Call to an undefined method Doctrine\\ORM\\EntityRepository#' + - '#Call to an undefined method Symfony\\Component\\Config\\Definition\\Builder\\NodeDefinition::#' + - '#Access to an undefined property Symfony\\Component\\Validator\\Constraint::#' + - '#RZ\\Roadiz\\Core\\Entities\\Tag does not have a field named \$translation#' + - '#does not have a field named \$node\.home#' + - '#does not have a field named \$node\.id#' + - '#does not have a field named \$node\.parent#' + - '#does not have a field named \$translation#' + # PHPStan Doctrine does not support ResolveTargetEntityListener + - '#Property ([a-zA-Z\\\:\$]+) type mapping mismatch: property can contain ([a-zA-Z\\\&\>\<]+)Interface\>?\|null but database expects ([a-zA-Z\\\&\>\<]+)\|null#' + - '#Property ([a-zA-Z\\\:\$]+) type mapping mismatch: property can contain ([a-zA-Z\\\&\>\<]+)Interface\>? but database expects ([a-zA-Z\\\&\>\<]+)#' + - '#type mapping mismatch: database can contain array\|bool\|float\|int\|JsonSerializable\|stdClass\|string\|null but property expects array\|null#' + - '#Doctrine\\ORM\\Mapping\\GeneratedValue constructor expects#' + + reportUnmatchedIgnoredErrors: false + checkGenericClassInNonGenericObjectType: false + checkMissingIterableValueType: false + +includes: + - vendor/phpstan/phpstan-doctrine/extension.neon + - vendor/phpstan/phpstan-doctrine/rules.neon diff --git a/lib/Rozier/src/.babelrc b/lib/Rozier/src/.babelrc new file mode 100644 index 00000000..2595c191 --- /dev/null +++ b/lib/Rozier/src/.babelrc @@ -0,0 +1,4 @@ +{ + "presets": ["es2015", "stage-0"], + "plugins": ["transform-runtime", "lodash"] +} diff --git a/lib/Rozier/src/.editorconfig b/lib/Rozier/src/.editorconfig new file mode 100644 index 00000000..c2778daa --- /dev/null +++ b/lib/Rozier/src/.editorconfig @@ -0,0 +1,21 @@ +# TheatreTheme editor config for contributors +# Root is false as your theme is inside Roadiz filetree +# http://editorconfig.org/ +root = false + +[*] +indent_style = space +indent_size = 4 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.md] +trim_trailing_whitespace = false + +[Makefile] +indent_style = tab + +[package.json] +indent_size = 2 diff --git a/lib/Rozier/src/.eslintignore b/lib/Rozier/src/.eslintignore new file mode 100644 index 00000000..4544c1c1 --- /dev/null +++ b/lib/Rozier/src/.eslintignore @@ -0,0 +1,9 @@ +/build/ +/config/ +/dist/ +/static/ +/Resources/app/vendor +/Resources/app/less +/Resources/app/scss +/Resources/app/assets +/node_modules/ diff --git a/lib/Rozier/src/.eslintrc.js b/lib/Rozier/src/.eslintrc.js new file mode 100644 index 00000000..536f9132 --- /dev/null +++ b/lib/Rozier/src/.eslintrc.js @@ -0,0 +1,25 @@ +// https://eslint.org/docs/user-guide/configuring +module.exports = { + root: true, + parser: 'babel-eslint', + parserOptions: { + sourceType: 'module', + }, + env: { + browser: true, + es6: true, + }, + // https://github.com/standard/standard/blob/master/docs/RULES-en.md + //extends: 'standard', + extends: ['prettier', 'plugin:prettier/recommended', 'plugin:vue/essential'], + // required to lint *.vue files + plugins: ['html', 'prettier'], + // add your custom rules here + rules: { + indent: ['warn', 4, { SwitchCase: 1 }], + // allow async-await + 'generator-star-spacing': 'off', + // allow debugger during development + 'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off', + }, +} diff --git a/lib/Rozier/src/.gitignore b/lib/Rozier/src/.gitignore new file mode 100644 index 00000000..541a820f --- /dev/null +++ b/lib/Rozier/src/.gitignore @@ -0,0 +1,14 @@ +.DS_Store +node_modules/ +/dist/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Editor directories and files +.idea +.vscode +*.suo +*.ntvs* +*.njsproj +*.sln diff --git a/lib/Rozier/src/.postcssrc.js b/lib/Rozier/src/.postcssrc.js new file mode 100644 index 00000000..249472da --- /dev/null +++ b/lib/Rozier/src/.postcssrc.js @@ -0,0 +1,9 @@ +// https://github.com/michael-ciniawsky/postcss-load-config + +module.exports = { + "plugins": { + // to edit target browsers: use "browserslist" field in package.json + "postcss-import": {}, + "autoprefixer": {} + } +} diff --git a/lib/Rozier/src/.prettierrc.js b/lib/Rozier/src/.prettierrc.js new file mode 100644 index 00000000..d9536842 --- /dev/null +++ b/lib/Rozier/src/.prettierrc.js @@ -0,0 +1,6 @@ + +module.exports = { + "singleQuote": true, + "semi": false, + "printWidth": 120 +} diff --git a/lib/Rozier/src/AjaxControllers/AbstractAjaxController.php b/lib/Rozier/src/AjaxControllers/AbstractAjaxController.php new file mode 100644 index 00000000..4426d3a3 --- /dev/null +++ b/lib/Rozier/src/AjaxControllers/AbstractAjaxController.php @@ -0,0 +1,67 @@ +get('_action') == "") { + throw new BadRequestHttpException('Wrong action requested'); + } + + if ($requestCsrfToken === true) { + if (!$this->isCsrfTokenValid(static::AJAX_TOKEN_INTENTION, $request->get('_token'))) { + throw new BadRequestHttpException('Bad CSRF token'); + } + } + + if ( + in_array(strtolower($method), static::$validMethods) && + strtolower($request->getMethod()) != strtolower($method) + ) { + throw new BadRequestHttpException('Bad method'); + } + + return true; + } + + protected function sortIsh(array &$arr, array $map): array + { + $return = []; + + while ($element = array_shift($map)) { + foreach ($arr as $key => $value) { + if ($element == $value->getId()) { + $return[] = $value; + unset($arr[$key]); + break 1; + } + } + } + + return $return; + } +} diff --git a/lib/Rozier/src/AjaxControllers/AjaxAbstractFieldsController.php b/lib/Rozier/src/AjaxControllers/AjaxAbstractFieldsController.php new file mode 100644 index 00000000..0c597186 --- /dev/null +++ b/lib/Rozier/src/AjaxControllers/AjaxAbstractFieldsController.php @@ -0,0 +1,108 @@ +handlerFactory = $handlerFactory; + } + + /** + * Handle actions for any abstract fields. + * + * @param Request $request + * @param AbstractField|null $field + * + * @return null|Response + */ + protected function handleFieldActions(Request $request, AbstractField $field = null) + { + /* + * Validate + */ + $this->validateRequest($request); + + if ($field !== null) { + $responseArray = null; + + /* + * Get the right update method against "_action" parameter + */ + switch ($request->get('_action')) { + case 'updatePosition': + $responseArray = $this->updatePosition($request->request->all(), $field); + break; + } + + if ($responseArray === null) { + $responseArray = [ + 'statusCode' => '200', + 'status' => 'success', + 'responseText' => $this->getTranslator()->trans('field.%name%.updated', [ + '%name%' => $field->getName(), + ]), + ]; + } + + return new JsonResponse( + $responseArray, + Response::HTTP_PARTIAL_CONTENT + ); + } + + return null; + } + + /** + * @param array $parameters + * @param AbstractField|null $field + * + * @return array + */ + protected function updatePosition(array $parameters, AbstractField $field = null): array + { + /* + * First, we set the new parent + */ + if (!empty($parameters['newPosition']) && null !== $field) { + $field->setPosition((float) $parameters['newPosition']); + // Apply position update before cleaning + $this->em()->flush(); + $handler = $this->handlerFactory->getHandler($field); + $handler->cleanPositions(); + $this->em()->flush(); + return [ + 'statusCode' => '200', + 'status' => 'success', + 'responseText' => $this->getTranslator()->trans('field.%name%.updated', [ + '%name%' => $field->getName(), + ]), + ]; + } + return [ + 'statusCode' => '400', + 'status' => 'error', + 'responseText' => $this->getTranslator()->trans('field.%name%.updated', [ + '%name%' => $field->getName(), + ]), + ]; + } +} diff --git a/lib/Rozier/src/AjaxControllers/AjaxAttributeValuesController.php b/lib/Rozier/src/AjaxControllers/AjaxAttributeValuesController.php new file mode 100644 index 00000000..73f5adf1 --- /dev/null +++ b/lib/Rozier/src/AjaxControllers/AjaxAttributeValuesController.php @@ -0,0 +1,103 @@ +validateRequest($request, 'POST', false); + $this->denyAccessUnlessGranted('ROLE_ACCESS_NODE_ATTRIBUTES'); + + /** @var AttributeValue|null $attributeValue */ + $attributeValue = $this->em()->find(AttributeValue::class, (int) $attributeValueId); + + if ($attributeValue !== null) { + $responseArray = []; + /* + * Get the right update method against "_action" parameter + */ + switch ($request->get('_action')) { + case 'updatePosition': + $responseArray = $this->updatePosition($request->request->all(), $attributeValue); + break; + } + + return new JsonResponse( + $responseArray, + Response::HTTP_PARTIAL_CONTENT + ); + } + + throw $this->createNotFoundException($this->getTranslator()->trans( + 'attribute_value.%attributeValueId%.not_exists', + [ + '%attributeValueId%' => $attributeValueId + ] + )); + } + + /** + * @param array $parameters + * @param AttributeValue $attributeValue + * + * @return array + */ + protected function updatePosition($parameters, AttributeValue $attributeValue): array + { + $attributable = $attributeValue->getAttributable(); + $details = [ + '%name%' => $attributeValue->getAttribute()->getLabelOrCode(), + '%nodeName%' => $attributable instanceof Node ? $attributable->getNodeName() : '', + ]; + /* + * First, we set the new parent + */ + if (!empty($parameters['newPosition'])) { + $attributeValue->setPosition((float) $parameters['newPosition']); + // Apply position update before cleaning + $this->em()->flush(); + return [ + 'statusCode' => '200', + 'status' => 'success', + 'responseText' => $this->getTranslator()->trans( + 'attribute_value_translation.%name%.updated_from_node.%nodeName%', + $details + ), + ]; + } + + return [ + 'statusCode' => '400', + 'status' => 'error', + 'responseText' => $this->getTranslator()->trans( + 'attribute_value_translation.%name%.updated_from_node.%nodeName%', + $details + ), + ]; + } +} diff --git a/lib/Rozier/src/AjaxControllers/AjaxCustomFormFieldsController.php b/lib/Rozier/src/AjaxControllers/AjaxCustomFormFieldsController.php new file mode 100644 index 00000000..f6075f8d --- /dev/null +++ b/lib/Rozier/src/AjaxControllers/AjaxCustomFormFieldsController.php @@ -0,0 +1,46 @@ +validateRequest($request); + $this->denyAccessUnlessGranted('ROLE_ACCESS_CUSTOMFORMS_DELETE'); + + $field = $this->em()->find(CustomFormField::class, (int) $customFormFieldId); + + if (null !== $field && null !== $response = $this->handleFieldActions($request, $field)) { + return $response; + } + + throw $this->createNotFoundException($this->getTranslator()->trans( + 'field.%customFormFieldId%.not_exists', + [ + '%customFormFieldId%' => $customFormFieldId + ] + )); + } +} diff --git a/lib/Rozier/src/AjaxControllers/AjaxCustomFormsExplorerController.php b/lib/Rozier/src/AjaxControllers/AjaxCustomFormsExplorerController.php new file mode 100644 index 00000000..901e6004 --- /dev/null +++ b/lib/Rozier/src/AjaxControllers/AjaxCustomFormsExplorerController.php @@ -0,0 +1,126 @@ +urlGenerator = $urlGenerator; + } + + /** + * @param Request $request + * + * @return Response JSON response + */ + public function indexAction(Request $request) + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_NODES'); + + $arrayFilter = []; + /* + * Manage get request to filter list + */ + $listManager = $this->createEntityListManager( + CustomForm::class, + $arrayFilter, + ['createdAt' => 'DESC'] + ); + $listManager->setDisplayingNotPublishedNodes(true); + $listManager->setItemPerPage(40); + $listManager->handle(); + + $customForms = $listManager->getEntities(); + + $customFormsArray = $this->normalizeCustomForms($customForms); + + $responseArray = [ + 'status' => 'confirm', + 'statusCode' => 200, + 'customForms' => $customFormsArray, + 'customFormsCount' => count($customForms), + 'filters' => $listManager->getAssignation(), + ]; + + return new JsonResponse( + $responseArray + ); + } + + /** + * Get a CustomForm list from an array of id. + * + * @param Request $request + * @return JsonResponse + */ + public function listAction(Request $request) + { + if (!$request->query->has('ids')) { + throw new InvalidParameterException('Ids should be provided within an array'); + } + + $this->denyAccessUnlessGranted('ROLE_ACCESS_NODES'); + + $cleanCustomFormsIds = array_filter($request->query->filter('ids', [], \FILTER_DEFAULT, [ + 'flags' => \FILTER_FORCE_ARRAY + ])); + $customFormsArray = []; + + if (count($cleanCustomFormsIds)) { + /** @var EntityManager $em */ + $em = $this->em(); + $customForms = $em->getRepository(CustomForm::class)->findBy([ + 'id' => $cleanCustomFormsIds, + ]); + // Sort array by ids given in request + $customForms = $this->sortIsh($customForms, $cleanCustomFormsIds); + $customFormsArray = $this->normalizeCustomForms($customForms); + } + + $responseArray = [ + 'status' => 'confirm', + 'statusCode' => 200, + 'forms' => $customFormsArray + ]; + + return new JsonResponse( + $responseArray + ); + } + + /** + * Normalize response CustomForm list result. + * + * @param array|\Traversable $customForms + * @return array + */ + private function normalizeCustomForms($customForms) + { + $customFormsArray = []; + + /** @var CustomForm $customForm */ + foreach ($customForms as $customForm) { + $customFormModel = new CustomFormModel($customForm, $this->urlGenerator, $this->getTranslator()); + $customFormsArray[] = $customFormModel->toArray(); + } + + return $customFormsArray; + } +} diff --git a/lib/Rozier/src/AjaxControllers/AjaxDocumentsExplorerController.php b/lib/Rozier/src/AjaxControllers/AjaxDocumentsExplorerController.php new file mode 100644 index 00000000..332e5ee5 --- /dev/null +++ b/lib/Rozier/src/AjaxControllers/AjaxDocumentsExplorerController.php @@ -0,0 +1,188 @@ +renderer = $renderer; + $this->documentUrlGenerator = $documentUrlGenerator; + $this->urlGenerator = $urlGenerator; + $this->embedFinderFactory = $embedFinderFactory; + } + + public static array $thumbnailArray = [ + "fit" => "40x40", + "quality" => 50, + "inline" => false, + ]; + /** + * @param Request $request + * + * @return Response JSON response + */ + public function indexAction(Request $request) + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_DOCUMENTS'); + + /* + * Prevent raw document to show in explorer. + */ + $arrayFilter = [ + 'raw' => false, + ]; + + if ($request->query->has('folderId') && $request->get('folderId') > 0) { + $folder = $this->em() + ->find( + Folder::class, + $request->get('folderId') + ); + + $arrayFilter['folders'] = [$folder]; + } + /* + * Manage get request to filter list + */ + $listManager = $this->createEntityListManager( + Document::class, + $arrayFilter, + [ + 'createdAt' => 'DESC' + ] + ); + $listManager->setDisplayingNotPublishedNodes(true); + $listManager->setItemPerPage(30); + $listManager->handle(); + + $documents = $listManager->getEntities(); + $documentsArray = $this->normalizeDocuments($documents); + + $responseArray = [ + 'status' => 'confirm', + 'statusCode' => 200, + 'documents' => $documentsArray, + 'documentsCount' => count($documents), + 'filters' => $listManager->getAssignation(), + 'trans' => $this->getTrans(), + ]; + + if ($request->query->has('folderId') && $request->get('folderId') > 0) { + $responseArray['filters'] = array_merge($responseArray['filters'], [ + 'folderId' => $request->get('folderId') + ]); + } + + return new JsonResponse( + $responseArray + ); + } + + /** + * Get a Document list from an array of id. + * + * @param Request $request + * @return JsonResponse + */ + public function listAction(Request $request) + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_DOCUMENTS'); + + if (!$request->query->has('ids')) { + throw new InvalidParameterException('Ids should be provided within an array'); + } + $cleanDocumentIds = array_filter($request->query->filter('ids', [], \FILTER_DEFAULT, [ + 'flags' => \FILTER_FORCE_ARRAY + ])); + $documentsArray = []; + + if (count($cleanDocumentIds)) { + $em = $this->em(); + $documents = $em->getRepository(Document::class)->findBy([ + 'id' => $cleanDocumentIds, + 'raw' => false, + ]); + // Sort array by ids given in request + $documents = $this->sortIsh($documents, $cleanDocumentIds); + $documentsArray = $this->normalizeDocuments($documents); + } + + $responseArray = [ + 'status' => 'confirm', + 'statusCode' => 200, + 'documents' => $documentsArray, + 'trans' => $this->getTrans() + ]; + + return new JsonResponse( + $responseArray + ); + } + + /** + * Normalize response Document list result. + * + * @param array|\Traversable $documents + * @return array + */ + private function normalizeDocuments($documents) + { + $documentsArray = []; + + /** @var Document $doc */ + foreach ($documents as $doc) { + $documentModel = new DocumentModel( + $doc, + $this->renderer, + $this->documentUrlGenerator, + $this->urlGenerator, + $this->embedFinderFactory + ); + $documentsArray[] = $documentModel->toArray(); + } + + return $documentsArray; + } + + /** + * Get an array of translations. + * + * @return array + */ + private function getTrans() + { + return [ + 'editDocument' => $this->getTranslator()->trans('edit.document'), + 'unlinkDocument' => $this->getTranslator()->trans('unlink.document'), + 'linkDocument' => $this->getTranslator()->trans('link.document'), + 'moreItems' => $this->getTranslator()->trans('more.documents') + ]; + } +} diff --git a/lib/Rozier/src/AjaxControllers/AjaxEntitiesExplorerController.php b/lib/Rozier/src/AjaxControllers/AjaxEntitiesExplorerController.php new file mode 100644 index 00000000..4557121d --- /dev/null +++ b/lib/Rozier/src/AjaxControllers/AjaxEntitiesExplorerController.php @@ -0,0 +1,211 @@ +renderer = $renderer; + $this->documentUrlGenerator = $documentUrlGenerator; + $this->urlGenerator = $urlGenerator; + $this->embedFinderFactory = $embedFinderFactory; + } + + /** + * @param NodeTypeField $nodeTypeField + * @return array + */ + protected function getFieldConfiguration(NodeTypeField $nodeTypeField): array + { + if ( + $nodeTypeField->getType() !== AbstractField::MANY_TO_MANY_T && + $nodeTypeField->getType() !== AbstractField::MANY_TO_ONE_T + ) { + throw new InvalidParameterException('nodeTypeField is not a valid entity join.'); + } + + $configs = [ + Yaml::parse($nodeTypeField->getDefaultValues() ?? ''), + ]; + $processor = new Processor(); + $joinConfig = new JoinNodeTypeFieldConfiguration(); + + return $processor->processConfiguration($joinConfig, $configs); + } + + public function indexAction(Request $request): JsonResponse + { + $this->denyAccessUnlessGranted('ROLE_BACKEND_USER'); + + if (!$request->query->has('nodeTypeFieldId')) { + throw new InvalidParameterException('nodeTypeFieldId parameter is missing.'); + } + + /** @var NodeTypeField $nodeTypeField */ + $nodeTypeField = $this->em()->find(NodeTypeField::class, $request->query->get('nodeTypeFieldId')); + $configuration = $this->getFieldConfiguration($nodeTypeField); + /** @var class-string $className */ + $className = $configuration['classname']; + + $orderBy = []; + foreach ($configuration['orderBy'] as $order) { + $orderBy[$order['field']] = $order['direction']; + } + + $criteria = []; + foreach ($configuration['where'] as $where) { + $criteria[$where['field']] = $where['value']; + } + + /* + * Manage get request to filter list + */ + $listManager = $this->createEntityListManager( + $className, + $criteria, + $orderBy + ); + $listManager->setDisplayingNotPublishedNodes(true); + $listManager->setItemPerPage(30); + $listManager->handle(); + $entities = $listManager->getEntities(); + + $entitiesArray = $this->normalizeEntities($entities, $configuration); + + $responseArray = [ + 'status' => 'confirm', + 'statusCode' => 200, + 'entities' => $entitiesArray, + 'filters' => $listManager->getAssignation(), + ]; + + return new JsonResponse( + $responseArray + ); + } + + /** + * Get a Node list from an array of id. + * + * @param Request $request + * @return JsonResponse + */ + public function listAction(Request $request): JsonResponse + { + if (!$request->query->has('nodeTypeFieldId')) { + throw new InvalidParameterException('nodeTypeFieldId parameter is missing.'); + } + + if (!$request->query->has('ids')) { + throw new InvalidParameterException('Ids should be provided within an array'); + } + + $this->denyAccessUnlessGranted('ROLE_ACCESS_NODES'); + + /** @var EntityManager $em */ + $em = $this->em(); + + /** @var NodeTypeField $nodeTypeField */ + $nodeTypeField = $this->em()->find(NodeTypeField::class, $request->query->get('nodeTypeFieldId')); + $configuration = $this->getFieldConfiguration($nodeTypeField); + /** @var class-string $className */ + $className = $configuration['classname']; + + $cleanNodeIds = array_filter($request->query->filter('ids', [], \FILTER_DEFAULT, [ + 'flags' => \FILTER_FORCE_ARRAY + ])); + $entitiesArray = []; + + if (count($cleanNodeIds)) { + $entities = $em->getRepository($className)->findBy([ + 'id' => $cleanNodeIds, + ]); + + // Sort array by ids given in request + $entities = $this->sortIsh($entities, $cleanNodeIds); + $entitiesArray = $this->normalizeEntities($entities, $configuration); + } + + $responseArray = [ + 'status' => 'confirm', + 'statusCode' => 200, + 'items' => $entitiesArray + ]; + + return new JsonResponse( + $responseArray + ); + } + + /** + * Normalize response Node list result. + * + * @param iterable $entities + * @param array $configuration + * @return array + */ + private function normalizeEntities(iterable $entities, array &$configuration): array + { + $entitiesArray = []; + + /** @var PersistableInterface $entity */ + foreach ($entities as $entity) { + if ($entity instanceof Folder) { + $explorerItem = new FolderExplorerItem($entity, $this->urlGenerator); + } elseif ($entity instanceof Setting) { + $explorerItem = new SettingExplorerItem($entity, $this->urlGenerator); + } elseif ($entity instanceof User) { + $explorerItem = new UserExplorerItem($entity, $this->urlGenerator); + } else { + $explorerItem = new ConfigurableExplorerItem( + $entity, + $configuration, + $this->renderer, + $this->documentUrlGenerator, + $this->urlGenerator, + $this->embedFinderFactory + ); + } + $entitiesArray[] = $explorerItem->toArray(); + } + + return $entitiesArray; + } +} diff --git a/lib/Rozier/src/AjaxControllers/AjaxExplorerProviderController.php b/lib/Rozier/src/AjaxControllers/AjaxExplorerProviderController.php new file mode 100644 index 00000000..b9ee2e04 --- /dev/null +++ b/lib/Rozier/src/AjaxControllers/AjaxExplorerProviderController.php @@ -0,0 +1,153 @@ +psrContainer = $psrContainer; + } + + /** + * @param class-string $providerClass + * @return ExplorerProviderInterface + * @throws \Psr\Container\ContainerExceptionInterface + * @throws \Psr\Container\NotFoundExceptionInterface + */ + protected function getProvider(string $providerClass): ExplorerProviderInterface + { + if ($this->psrContainer->has($providerClass)) { + return $this->psrContainer->get($providerClass); + } + return new $providerClass(); + } + /** + * @param Request $request + * @return Response JSON response + */ + public function indexAction(Request $request) + { + $this->denyAccessUnlessGranted('ROLE_BACKEND_USER'); + + if (!$request->query->has('providerClass')) { + throw new InvalidParameterException('providerClass parameter is missing.'); + } + + $providerClass = $request->query->get('providerClass'); + if (!class_exists($providerClass)) { + throw new InvalidParameterException('providerClass is not a valid class.'); + } + + $provider = $this->getProvider($providerClass); + if ($provider instanceof AbstractExplorerProvider) { + $provider->setContainer($this->psrContainer); + } + $options = [ + 'page' => $request->query->get('page') ?: 1, + 'itemPerPage' => $request->query->get('itemPerPage') ?: 30, + 'search' => $request->query->get('search') ?: null, + ]; + if ($request->query->has('options')) { + $options = array_merge( + array_filter($request->query->filter('options', [], \FILTER_DEFAULT, [ + 'flags' => \FILTER_FORCE_ARRAY + ])), + $options + ); + } + $entities = $provider->getItems($options); + + $entitiesArray = []; + foreach ($entities as $entity) { + if ($entity instanceof ExplorerItemInterface) { + $entitiesArray[] = $entity->toArray(); + } + } + + $responseArray = [ + 'status' => 'confirm', + 'statusCode' => 200, + 'entities' => $entitiesArray, + 'filters' => $provider->getFilters($options), + ]; + + return new JsonResponse( + $responseArray, + Response::HTTP_PARTIAL_CONTENT + ); + } + + /** + * Get a Node list from an array of id. + * + * @param Request $request + * @return JsonResponse + */ + public function listAction(Request $request) + { + if (!$request->query->has('providerClass')) { + throw new InvalidParameterException('providerClass parameter is missing.'); + } + + $providerClass = $request->query->get('providerClass'); + if (!class_exists($providerClass)) { + throw new InvalidParameterException('providerClass is not a valid class.'); + } + + if (!$request->query->has('ids')) { + throw new InvalidParameterException('Ids should be provided within an array'); + } + + $this->denyAccessUnlessGranted('ROLE_BACKEND_USER'); + + $provider = $this->getProvider($providerClass); + if ($provider instanceof AbstractExplorerProvider) { + $provider->setContainer($this->psrContainer); + } + $entitiesArray = []; + $cleanNodeIds = array_filter($request->query->filter('ids', [], \FILTER_DEFAULT, [ + 'flags' => \FILTER_FORCE_ARRAY + ])); + $cleanNodeIds = array_filter($cleanNodeIds, function ($value) { + $nullValues = ['null', null, 0, '0', false, 'false']; + return !in_array($value, $nullValues, true); + }); + + if (count($cleanNodeIds) > 0) { + $entities = $provider->getItemsById($cleanNodeIds); + + foreach ($entities as $entity) { + if ($entity instanceof ExplorerItemInterface) { + $entitiesArray[] = $entity->toArray(); + } + } + } + + $responseArray = [ + 'status' => 'confirm', + 'statusCode' => 200, + 'items' => $entitiesArray + ]; + + return new JsonResponse( + $responseArray + ); + } +} diff --git a/lib/Rozier/src/AjaxControllers/AjaxFolderTreeController.php b/lib/Rozier/src/AjaxControllers/AjaxFolderTreeController.php new file mode 100644 index 00000000..9644b1e6 --- /dev/null +++ b/lib/Rozier/src/AjaxControllers/AjaxFolderTreeController.php @@ -0,0 +1,82 @@ +treeWidgetFactory = $treeWidgetFactory; + } + + /** + * @param Request $request + * + * @return JsonResponse + * @throws \Twig\Error\LoaderError + * @throws \Twig\Error\RuntimeError + * @throws \Twig\Error\SyntaxError + */ + public function getTreeAction(Request $request) + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_DOCUMENTS'); + + /** @var FolderTreeWidget|null $folderTree */ + $folderTree = null; + + switch ($request->get("_action")) { + /* + * Inner folder edit for folderTree + */ + case 'requestFolderTree': + if ($request->get('parentFolderId') > 0) { + $folder = $this->em() + ->find( + Folder::class, + (int) $request->get('parentFolderId') + ); + } else { + $folder = null; + } + + $folderTree = $this->treeWidgetFactory->createFolderTree($folder); + + $this->assignation['mainFolderTree'] = false; + + break; + /* + * Main panel tree folderTree + */ + case 'requestMainFolderTree': + $parent = null; + $folderTree = $this->treeWidgetFactory->createFolderTree($parent); + $this->assignation['mainFolderTree'] = true; + break; + } + + $this->assignation['folderTree'] = $folderTree; + + $responseArray = [ + 'statusCode' => '200', + 'status' => 'success', + 'folderTree' => $this->getTwig()->render('@RoadizRozier/widgets/folderTree/folderTree.html.twig', $this->assignation), + ]; + + return new JsonResponse( + $responseArray + ); + } +} diff --git a/lib/Rozier/src/AjaxControllers/AjaxFoldersController.php b/lib/Rozier/src/AjaxControllers/AjaxFoldersController.php new file mode 100644 index 00000000..3162ecc7 --- /dev/null +++ b/lib/Rozier/src/AjaxControllers/AjaxFoldersController.php @@ -0,0 +1,174 @@ +handlerFactory = $handlerFactory; + } + + /** + * Handle AJAX edition requests for Folder + * such as coming from tag-tree widgets. + * + * @param Request $request + * @param int $folderId + * + * @return Response JSON response + */ + public function editAction(Request $request, int $folderId) + { + $this->validateRequest($request); + $this->denyAccessUnlessGranted('ROLE_ACCESS_DOCUMENTS'); + + $folder = $this->em()->find(Folder::class, (int) $folderId); + + if ($folder !== null) { + $responseArray = null; + + /* + * Get the right update method against "_action" parameter + */ + switch ($request->get('_action')) { + case 'updatePosition': + $this->updatePosition($request->request->all(), $folder); + break; + } + + if ($responseArray === null) { + $responseArray = [ + 'statusCode' => '200', + 'status' => 'success', + 'responseText' => $this->getTranslator()->trans('folder.%name%.updated', [ + '%name%' => $folder->getName(), + ]) + ]; + } + + return new JsonResponse( + $responseArray, + Response::HTTP_PARTIAL_CONTENT + ); + } + + + $responseArray = [ + 'statusCode' => '403', + 'status' => 'danger', + 'responseText' => $this->getTranslator()->trans('folder.does_not_exist') + ]; + + return new JsonResponse( + $responseArray, + Response::HTTP_OK + ); + } + + /** + * @param Request $request + * @return JsonResponse + */ + public function searchAction(Request $request) + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_DOCUMENTS'); + + if ($request->query->has('search') && $request->get('search') != "") { + $responseArray = []; + + $pattern = strip_tags($request->get('search')); + $folders = $this->em() + ->getRepository(Folder::class) + ->searchBy( + $pattern, + [], + [], + 10 + ); + /** @var Folder $folder */ + foreach ($folders as $folder) { + $responseArray[] = $folder->getFullPath(); + } + + return new JsonResponse( + $responseArray, + Response::HTTP_OK + ); + } + + throw $this->createNotFoundException($this->getTranslator()->trans('no.folder.found')); + } + + /** + * @param array $parameters + * @param Folder $folder + */ + protected function updatePosition($parameters, Folder $folder): void + { + /* + * First, we set the new parent + */ + $parent = null; + + if ( + !empty($parameters['newParent']) && + $parameters['newParent'] > 0 + ) { + /** @var Folder $parent */ + $parent = $this->em()->find(Folder::class, (int) $parameters['newParent']); + + if ($parent !== null) { + $folder->setParent($parent); + } + } else { + $folder->setParent(null); + } + + /* + * Then compute new position + */ + if ( + !empty($parameters['nextFolderId']) && + $parameters['nextFolderId'] > 0 + ) { + /** @var Folder $nextFolder */ + $nextFolder = $this->em()->find(Folder::class, (int) $parameters['nextFolderId']); + if ($nextFolder !== null) { + $folder->setPosition($nextFolder->getPosition() - 0.5); + } + } elseif ( + !empty($parameters['prevFolderId']) && + $parameters['prevFolderId'] > 0 + ) { + /** @var Folder $prevFolder */ + $prevFolder = $this->em() + ->find(Folder::class, (int) $parameters['prevFolderId']); + if ($prevFolder !== null) { + $folder->setPosition($prevFolder->getPosition() + 0.5); + } + } + // Apply position update before cleaning + $this->em()->flush(); + + /** @var FolderHandler $handler */ + $handler = $this->handlerFactory->getHandler($folder); + $handler->cleanPositions(); + + $this->em()->flush(); + } +} diff --git a/lib/Rozier/src/AjaxControllers/AjaxFoldersExplorerController.php b/lib/Rozier/src/AjaxControllers/AjaxFoldersExplorerController.php new file mode 100644 index 00000000..3617a29a --- /dev/null +++ b/lib/Rozier/src/AjaxControllers/AjaxFoldersExplorerController.php @@ -0,0 +1,66 @@ +denyAccessUnlessGranted('ROLE_ACCESS_DOCUMENTS'); + + $folders = $this->em() + ->getRepository(Folder::class) + ->findBy( + [ + 'parent' => null, + ], + [ + 'position' => 'ASC', + ] + ); + + $responseArray = [ + 'status' => 'confirm', + 'statusCode' => 200, + 'folders' => $this->recurseFolders($folders), + ]; + + return new JsonResponse( + $responseArray + ); + } + + protected function recurseFolders(?iterable $folders = null): array + { + $foldersArray = []; + if ($folders !== null) { + /** @var Folder $folder */ + foreach ($folders as $folder) { + $children = $this->recurseFolders($folder->getChildren()); + $foldersArray[] = [ + 'id' => $folder->getId(), + 'name' => $folder->getName(), + 'folderName' => $folder->getFolderName(), + 'children' => $children, + ]; + } + } + + return $foldersArray; + } +} diff --git a/lib/Rozier/src/AjaxControllers/AjaxNodeTreeController.php b/lib/Rozier/src/AjaxControllers/AjaxNodeTreeController.php new file mode 100644 index 00000000..3340ec5a --- /dev/null +++ b/lib/Rozier/src/AjaxControllers/AjaxNodeTreeController.php @@ -0,0 +1,146 @@ +nodeChrootResolver = $nodeChrootResolver; + $this->treeWidgetFactory = $treeWidgetFactory; + $this->nodeTypesBag = $nodeTypesBag; + } + + /** + * @param Request $request + * @param int|null $translationId + * + * @return JsonResponse + * @throws \Twig\Error\LoaderError + * @throws \Twig\Error\RuntimeError + * @throws \Twig\Error\SyntaxError + */ + public function getTreeAction(Request $request, ?int $translationId = null) + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_NODES'); + + if (null === $translationId) { + $translation = $this->em()->getRepository(Translation::class)->findDefault(); + } else { + $translation = $this->em()->find( + Translation::class, + $translationId + ); + } + + /** @var NodeTreeWidget|null $nodeTree */ + $nodeTree = null; + $linkedTypes = []; + + switch ($request->get("_action")) { + /* + * Inner node edit for nodeTree + */ + case 'requestNodeTree': + if ($request->get('parentNodeId') > 0) { + $node = $this->em() + ->find( + Node::class, + (int) $request->get('parentNodeId') + ); + } elseif (null !== $this->getUser()) { + $node = $this->nodeChrootResolver->getChroot($this->getUser()); + } else { + $node = null; + } + + $nodeTree = $this->treeWidgetFactory->createNodeTree($node, $translation); + + if ( + $request->get('tagId') && + $request->get('tagId') > 0 + ) { + $filterTag = $this->em() + ->find( + Tag::class, + (int) $request->get('tagId') + ); + + $nodeTree->setTag($filterTag); + } + + /* + * Filter view with only listed node-types + */ + $linkedTypes = $request->get('linkedTypes', []); + if (is_array($linkedTypes) && count($linkedTypes) > 0) { + $linkedTypes = array_filter(array_map(function (string $typeName) { + return $this->nodeTypesBag->get($typeName); + }, $linkedTypes)); + + $nodeTree->setAdditionalCriteria([ + 'nodeType' => $linkedTypes + ]); + } + + $this->assignation['mainNodeTree'] = false; + + if (true === (bool) $request->get('stackTree')) { + $nodeTree->setStackTree(true); + } + break; + /* + * Main panel tree nodeTree + */ + case 'requestMainNodeTree': + $parent = null; + if (null !== $this->getUser()) { + $parent = $this->nodeChrootResolver->getChroot($this->getUser()); + } + + $nodeTree = $this->treeWidgetFactory->createNodeTree($parent, $translation); + $this->assignation['mainNodeTree'] = true; + break; + } + + $this->assignation['nodeTree'] = $nodeTree; + // Need to expose linkedTypes to add data-attributes on widget again + $this->assignation['linkedTypes'] = $linkedTypes; + + $responseArray = [ + 'statusCode' => '200', + 'status' => 'success', + 'linkedTypes' => array_map(function (NodeType $nodeType) { + return $nodeType->getName(); + }, $linkedTypes), + 'nodeTree' => trim($this->getTwig()->render('@RoadizRozier/widgets/nodeTree/nodeTree.html.twig', $this->assignation)), + ]; + + return new JsonResponse( + $responseArray + ); + } +} diff --git a/lib/Rozier/src/AjaxControllers/AjaxNodeTypeFieldsController.php b/lib/Rozier/src/AjaxControllers/AjaxNodeTypeFieldsController.php new file mode 100644 index 00000000..672627ad --- /dev/null +++ b/lib/Rozier/src/AjaxControllers/AjaxNodeTypeFieldsController.php @@ -0,0 +1,46 @@ +validateRequest($request); + $this->denyAccessUnlessGranted('ROLE_ACCESS_NODEFIELDS_DELETE'); + + $field = $this->em()->find(NodeTypeField::class, (int) $nodeTypeFieldId); + + if (null !== $response = $this->handleFieldActions($request, $field)) { + return $response; + } + + throw $this->createNotFoundException($this->getTranslator()->trans( + 'field.%nodeTypeFieldId%.not_exists', + [ + '%nodeTypeFieldId%' => $nodeTypeFieldId + ] + )); + } +} diff --git a/lib/Rozier/src/AjaxControllers/AjaxNodeTypesController.php b/lib/Rozier/src/AjaxControllers/AjaxNodeTypesController.php new file mode 100644 index 00000000..3f45891e --- /dev/null +++ b/lib/Rozier/src/AjaxControllers/AjaxNodeTypesController.php @@ -0,0 +1,116 @@ +denyAccessUnlessGranted('ROLE_ACCESS_NODES'); + $arrayFilter = []; + + /* + * Manage get request to filter list + */ + $listManager = $this->createEntityListManager( + NodeType::class, + $arrayFilter + ); + $listManager->setDisplayingNotPublishedNodes(true); + $listManager->setItemPerPage(30); + $listManager->handle(); + + $nodeTypes = $listManager->getEntities(); + $documentsArray = $this->normalizeNodeType($nodeTypes); + + $responseArray = [ + 'status' => 'confirm', + 'statusCode' => 200, + 'nodeTypes' => $documentsArray, + 'nodeTypesCount' => count($nodeTypes), + 'filters' => $listManager->getAssignation() + ]; + + return new JsonResponse( + $responseArray + ); + } + + /** + * Get a NodeType list from an array of id. + * + * @param Request $request + * @return JsonResponse + */ + public function listAction(Request $request) + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_NODES'); + + if (!$request->query->has('names')) { + throw new InvalidParameterException('Names array should be provided within an array'); + } + + $cleanNodeTypesName = array_filter($request->query->filter('names', [], \FILTER_DEFAULT, [ + 'flags' => \FILTER_FORCE_ARRAY + ])); + $nodesArray = []; + + if (count($cleanNodeTypesName)) { + /** @var EntityManager $em */ + $em = $this->em(); + $nodeTypes = $em->getRepository(NodeType::class)->findBy([ + 'name' => $cleanNodeTypesName + ]); + + // Sort array by ids given in request + $nodesArray = $this->normalizeNodeType($nodeTypes); + } + + $responseArray = [ + 'status' => 'confirm', + 'statusCode' => 200, + 'items' => $nodesArray + ]; + + return new JsonResponse( + $responseArray + ); + } + + /** + * Normalize response NodeType list result. + * + * @param array|\Traversable $nodeTypes + * @return array + */ + private function normalizeNodeType($nodeTypes) + { + $nodeTypesArray = []; + + /** @var NodeType $nodeType */ + foreach ($nodeTypes as $nodeType) { + $nodeModel = new NodeTypeModel($nodeType); + $nodeTypesArray[] = $nodeModel->toArray(); + } + + return $nodeTypesArray; + } +} diff --git a/lib/Rozier/src/AjaxControllers/AjaxNodesController.php b/lib/Rozier/src/AjaxControllers/AjaxNodesController.php new file mode 100644 index 00000000..802fa451 --- /dev/null +++ b/lib/Rozier/src/AjaxControllers/AjaxNodesController.php @@ -0,0 +1,418 @@ +nodeNamePolicy = $nodeNamePolicy; + $this->logger = $logger; + $this->nodeMover = $nodeMover; + $this->nodeChrootResolver = $nodeChrootResolver; + $this->workflowRegistry = $workflowRegistry; + $this->uniqueNodeGenerator = $uniqueNodeGenerator; + } + + /** + * @param Request $request + * @param int $nodeId + * @return JsonResponse + */ + public function getTagsAction(Request $request, int $nodeId) + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_NODES'); + $tags = []; + /** @var Node $node */ + $node = $this->em()->find(Node::class, (int) $nodeId); + + /** @var Tag $tag */ + foreach ($node->getTags() as $tag) { + $tags[] = $tag->getFullPath(); + } + + return new JsonResponse( + $tags + ); + } + + /** + * Handle AJAX edition requests for Node + * such as coming from node-tree widgets. + * + * @param Request $request + * @param int $nodeId + * + * @return Response JSON response + */ + public function editAction(Request $request, $nodeId) + { + /* + * Validate + */ + $this->validateRequest($request); + $this->denyAccessUnlessGranted('ROLE_ACCESS_NODES'); + + /** @var Node|null $node */ + $node = $this->em()->find(Node::class, (int) $nodeId); + + if ($node !== null) { + $responseArray = null; + + /* + * Get the right update method against "_action" parameter + */ + switch ($request->get('_action')) { + case 'updatePosition': + $this->updatePosition($request->request->all(), $node); + break; + case 'duplicate': + $duplicator = new NodeDuplicator( + $node, + $this->em(), + $this->nodeNamePolicy + ); + $newNode = $duplicator->duplicate(); + /* + * Dispatch event + */ + $this->dispatchEvent(new NodeCreatedEvent($newNode)); + $this->dispatchEvent(new NodeDuplicatedEvent($newNode)); + + $msg = $this->getTranslator()->trans('duplicated.node.%name%', [ + '%name%' => $node->getNodeName(), + ]); + $this->logger->info($msg, ['source' => $newNode->getNodeSources()->first()]); + + $responseArray = [ + 'statusCode' => '200', + 'status' => 'success', + 'responseText' => $msg, + ]; + break; + } + + if ($responseArray === null) { + $responseArray = [ + 'statusCode' => '200', + 'status' => 'success', + 'responseText' => $this->getTranslator()->trans('node.%name%.updated', [ + '%name%' => $node->getNodeName(), + ]), + ]; + } + + return new JsonResponse( + $responseArray, + Response::HTTP_PARTIAL_CONTENT + ); + } + + throw $this->createNotFoundException($this->getTranslator()->trans('node.%nodeId%.not_exists', [ + '%nodeId%' => $nodeId, + ])); + } + + /** + * @param array $parameters + * @param Node $node + */ + protected function updatePosition($parameters, Node $node): void + { + if ($node->isLocked()) { + throw new BadRequestHttpException('Locked node cannot be moved.'); + } + /* + * First, we set the new parent + */ + $parent = $this->parseParentNode($parameters); + /* + * Then compute new position + */ + $position = $this->parsePosition($parameters, $node->getPosition()); + + try { + if ($node->getNodeType()->isReachable()) { + $oldPaths = $this->nodeMover->getNodeSourcesUrls($node); + } + } catch (SameNodeUrlException $e) { + $oldPaths = []; + } + + $this->nodeMover->move($node, $parent, $position); + $this->em()->flush(); + /* + * Dispatch event + */ + if (isset($oldPaths) && count($oldPaths) > 0 && !$node->isHome()) { + $this->logger->debug('NodesSources paths changed', ['paths' => $oldPaths]); + $this->dispatchEvent(new NodePathChangedEvent($node, $oldPaths)); + } else { + $this->logger->debug('NodesSources paths did not change'); + } + $this->dispatchEvent(new NodeUpdatedEvent($node)); + + foreach ($node->getNodeSources() as $nodeSource) { + $this->dispatchEvent(new NodesSourcesUpdatedEvent($nodeSource)); + } + + $this->em()->flush(); + } + + /** + * @param array $parameters + * + * @return Node|null + */ + protected function parseParentNode(array $parameters): ?Node + { + if (!empty($parameters['newParent']) && $parameters['newParent'] > 0) { + return $this->em()->find(Node::class, (int) $parameters['newParent']); + } elseif (null !== $this->getUser()) { + // If user is jailed in a node, prevent moving nodes out. + return $this->nodeChrootResolver->getChroot($this->getUser()); + } + return null; + } + + /** + * @param array $parameters + * @param float $default + * + * @return float + */ + protected function parsePosition(array $parameters, float $default = 0.0): float + { + if (key_exists('nextNodeId', $parameters) && (int) $parameters['nextNodeId'] > 0) { + /** @var Node $nextNode */ + $nextNode = $this->em()->find(Node::class, (int) $parameters['nextNodeId']); + if ($nextNode !== null) { + return $nextNode->getPosition() - 0.5; + } + } elseif (key_exists('prevNodeId', $parameters) && $parameters['prevNodeId'] > 0) { + /** @var Node $prevNode */ + $prevNode = $this->em()->find(Node::class, (int) $parameters['prevNodeId']); + if ($prevNode !== null) { + return $prevNode->getPosition() + 0.5; + } + } elseif (key_exists('firstPosition', $parameters) && (bool) $parameters['firstPosition'] === true) { + return -0.5; + } elseif (key_exists('lastPosition', $parameters) && (bool) $parameters['lastPosition'] === true) { + return 99999999; + } + return $default; + } + + /** + * Update node's status. + * + * @param Request $request + * + * @return JsonResponse + * @throws \Doctrine\ORM\ORMException + * @throws \Doctrine\ORM\OptimisticLockException + */ + public function statusesAction(Request $request): JsonResponse + { + $this->validateRequest($request); + $this->denyAccessUnlessGranted('ROLE_ACCESS_NODES'); + + if ($request->get('nodeId', 0) <= 0) { + throw new BadRequestHttpException($this->getTranslator()->trans('node.id.not_specified')); + } + + /** @var Node|null $node */ + $node = $this->em()->find(Node::class, (int) $request->get('nodeId')); + if (null === $node) { + throw $this->createNotFoundException($this->getTranslator()->trans('node.%nodeId%.not_exists', [ + '%nodeId%' => $request->get('nodeId'), + ])); + } + + $availableStatuses = [ + 'visible' => 'setVisible', + 'locked' => 'setLocked', + 'hideChildren' => 'setHidingChildren', + 'sterile' => 'setSterile', + ]; + + if ("nodeChangeStatus" == $request->get('_action') && "" != $request->get('statusName')) { + if ($request->get('statusName') === 'status') { + return $this->changeNodeStatus($node, $request->get('statusValue')); + } + + /* + * Check if status name is a valid boolean node field. + */ + if (in_array($request->get('statusName'), array_keys($availableStatuses))) { + $setter = $availableStatuses[$request->get('statusName')]; + $value = $request->get('statusValue'); + $node->$setter((bool) $value); + + /* + * If set locked to true, + * need to disable dynamic nodeName + */ + if ($request->get('statusName') == 'locked' && $value === true) { + $node->setDynamicNodeName(false); + } + + $this->em()->flush(); + + /* + * Dispatch event + */ + if ($request->get('statusName') === 'visible') { + $msg = $this->getTranslator()->trans('node.%name%.visibility_changed_to.%visible%', [ + '%name%' => $node->getNodeName(), + '%visible%' => $node->isVisible() ? $this->getTranslator()->trans('visible') : $this->getTranslator()->trans('invisible'), + ]); + $this->publishConfirmMessage($request, $msg, $node->getNodeSources()->first()); + $this->dispatchEvent(new NodeVisibilityChangedEvent($node)); + } else { + $msg = $this->getTranslator()->trans('node.%name%.%field%.updated', [ + '%name%' => $node->getNodeName(), + '%field%' => $request->get('statusName'), + ]); + $this->publishConfirmMessage($request, $msg, $node->getNodeSources()->first()); + } + $this->dispatchEvent(new NodeUpdatedEvent($node)); + $this->em()->flush(); + + $responseArray = [ + 'statusCode' => Response::HTTP_PARTIAL_CONTENT, + 'status' => 'success', + 'responseText' => $msg, + 'name' => $request->get('statusName'), + 'value' => $value, + ]; + } else { + throw new BadRequestHttpException($this->getTranslator()->trans('node.has_no.field.%field%', [ + '%field%' => $request->get('statusName'), + ])); + } + } else { + throw new BadRequestHttpException('Status field name is invalid.'); + } + + return new JsonResponse( + $responseArray, + $responseArray['statusCode'] + ); + } + + /** + * @param Node $node + * @param string $transition + * + * @return JsonResponse + */ + protected function changeNodeStatus(Node $node, string $transition): JsonResponse + { + $request = $this->getRequest(); + $workflow = $this->workflowRegistry->get($node); + + $workflow->apply($node, $transition); + $this->em()->flush(); + $msg = $this->getTranslator()->trans('node.%name%.status_changed_to.%status%', [ + '%name%' => $node->getNodeName(), + '%status%' => $this->getTranslator()->trans(Node::getStatusLabel($node->getStatus())), + ]); + $this->publishConfirmMessage($request, $msg, $node->getNodeSources()->first()); + + return new JsonResponse( + [ + 'statusCode' => Response::HTTP_PARTIAL_CONTENT, + 'status' => 'success', + 'responseText' => $msg, + 'name' => 'status', + 'value' => $transition, + ], + Response::HTTP_PARTIAL_CONTENT + ); + } + + /** + * @param Request $request + * @return JsonResponse + */ + public function quickAddAction(Request $request): JsonResponse + { + /* + * Validate + */ + $this->validateRequest($request); + $this->denyAccessUnlessGranted('ROLE_ACCESS_NODES'); + + try { + $source = $this->uniqueNodeGenerator->generateFromRequest($request); + + /* + * Dispatch event + */ + $this->dispatchEvent(new NodeCreatedEvent($source->getNode())); + + $msg = $this->getTranslator()->trans( + 'added.node.%name%', + [ + '%name%' => $source->getTitle(), + ] + ); + $this->publishConfirmMessage($request, $msg, $source); + + $responseArray = [ + 'statusCode' => Response::HTTP_CREATED, + 'status' => 'success', + 'responseText' => $msg, + ]; + } catch (\Exception $e) { + $msg = $this->getTranslator()->trans($e->getMessage()); + $this->logger->error($msg); + throw new BadRequestHttpException($msg); + } + + return new JsonResponse( + $responseArray, + $responseArray['statusCode'] + ); + } +} diff --git a/lib/Rozier/src/AjaxControllers/AjaxNodesExplorerController.php b/lib/Rozier/src/AjaxControllers/AjaxNodesExplorerController.php new file mode 100644 index 00000000..5842e159 --- /dev/null +++ b/lib/Rozier/src/AjaxControllers/AjaxNodesExplorerController.php @@ -0,0 +1,293 @@ +nodeSourceSearchHandler = $nodeSourceSearchHandler; + $this->nodeTypeApi = $nodeTypeApi; + $this->serializer = $serializer; + $this->urlGenerator = $urlGenerator; + $this->clientRegistry = $clientRegistry; + } + + protected function getItemPerPage(): int + { + return 30; + } + + protected function isSearchEngineAvailable(Request $request): bool + { + return $request->get('search') !== '' && null !== $this->clientRegistry->getClient(); + } + + /** + * @param Request $request + * + * @return Response JSON response + */ + public function indexAction(Request $request): Response + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_NODES'); + + $criteria = $this->parseFilterFromRequest($request); + $sorting = $this->parseSortingFromRequest($request); + if ($this->isSearchEngineAvailable($request)) { + $responseArray = $this->getSolrSearchResults($request, $criteria); + } else { + $responseArray = $this->getNodeSearchResults($request, $criteria, $sorting); + } + + if ($request->query->has('tagId') && $request->get('tagId') > 0) { + $responseArray['filters'] = array_merge($responseArray['filters'], [ + 'tagId' => $request->get('tagId') + ]); + } + + return $this->createSerializedResponse($responseArray); + } + + /** + * @param Request $request + * @return array + */ + protected function parseFilterFromRequest(Request $request): array + { + $arrayFilter = [ + 'status' => ['<=', Node::ARCHIVED], + ]; + + if ($request->query->has('tagId') && $request->get('tagId') > 0) { + $tag = $this->em() + ->find( + Tag::class, + $request->get('tagId') + ); + + $arrayFilter['tags'] = [$tag]; + } + + if ($request->query->has('nodeTypes') && count($request->get('nodeTypes')) > 0) { + $nodeTypeNames = array_map('trim', $request->get('nodeTypes')); + + $nodeTypes = $this->nodeTypeApi->getBy([ + 'name' => $nodeTypeNames, + ]); + + if (null !== $nodeTypes && count($nodeTypes) > 0) { + $arrayFilter['nodeType'] = $nodeTypes; + } + } + return $arrayFilter; + } + + /** + * @param Request $request + * @return array + */ + protected function parseSortingFromRequest(Request $request): array + { + if ($request->query->has('sort-alpha')) { + return [ + 'nodeName' => 'ASC', + ]; + } + + return [ + 'updatedAt' => 'DESC', + ]; + } + + /** + * @param Request $request + * @param array $criteria + * @param array $sorting + * @return array + */ + protected function getNodeSearchResults(Request $request, array $criteria, array $sorting = []): array + { + /* + * Manage get request to filter list + */ + $listManager = $this->createEntityListManager( + Node::class, + $criteria, + $sorting + ); + $listManager->setDisplayingNotPublishedNodes(true); + $listManager->setItemPerPage($this->getItemPerPage()); + $listManager->handle(); + + $nodes = $listManager->getEntities(); + $nodesArray = $this->normalizeNodes($nodes); + return [ + 'status' => 'confirm', + 'statusCode' => 200, + 'nodes' => $nodesArray, + 'nodesCount' => $listManager->getItemCount(), + 'filters' => $listManager->getAssignation(), + ]; + } + + /** + * @param Request $request + * @param array $arrayFilter + * + * @return array + */ + protected function getSolrSearchResults( + Request $request, + array $arrayFilter + ): array { + $this->nodeSourceSearchHandler->boostByUpdateDate(); + $currentPage = $request->get('page', 1); + + $results = $this->nodeSourceSearchHandler->search( + $request->get('search'), + $arrayFilter, + $this->getItemPerPage(), + true, + 10000000, + $currentPage + ); + $pageCount = ceil($results->getResultCount() / $this->getItemPerPage()); + $nodesArray = $this->normalizeNodes($results); + + return [ + 'status' => 'confirm', + 'statusCode' => 200, + 'nodes' => $nodesArray, + 'nodesCount' => $results->getResultCount(), + 'filters' => [ + 'currentPage' => $currentPage, + 'itemCount' => $results->getResultCount(), + 'itemPerPage' => $this->getItemPerPage(), + 'pageCount' => $pageCount, + 'nextPage' => $currentPage < $pageCount ? $currentPage + 1 : null, + ], + ]; + } + + /** + * Get a Node list from an array of id. + * + * @param Request $request + * @return JsonResponse + */ + public function listAction(Request $request): JsonResponse + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_NODES'); + + if (!$request->query->has('ids')) { + throw new InvalidParameterException('Ids should be provided within an array'); + } + + $cleanNodeIds = array_filter($request->query->filter('ids', [], \FILTER_DEFAULT, [ + 'flags' => \FILTER_FORCE_ARRAY + ])); + $nodesArray = []; + + if (count($cleanNodeIds)) { + /** @var EntityManager $em */ + $em = $this->em(); + $nodes = $em->getRepository(Node::class) + ->setDisplayingNotPublishedNodes(true) + ->findBy([ + 'id' => $cleanNodeIds, + ]); + + // Sort array by ids given in request + $nodes = $this->sortIsh($nodes, $cleanNodeIds); + $nodesArray = $this->normalizeNodes($nodes); + } + + return $this->createSerializedResponse([ + 'status' => 'confirm', + 'statusCode' => 200, + 'items' => $nodesArray + ]); + } + + /** + * Normalize response Node list result. + * + * @param array|\Traversable $nodes + * @return array + */ + private function normalizeNodes($nodes) + { + $nodesArray = []; + + foreach ($nodes as $node) { + if (null !== $node) { + if ($node instanceof NodesSources) { + if (!key_exists($node->getNode()->getId(), $nodesArray)) { + $nodeModel = new NodeSourceModel($node, $this->urlGenerator); + $nodesArray[$node->getNode()->getId()] = $nodeModel->toArray(); + } + } else { + if (!key_exists($node->getId(), $nodesArray)) { + $nodeModel = new NodeModel($node, $this->urlGenerator); + $nodesArray[$node->getId()] = $nodeModel->toArray(); + } + } + } + } + + return array_values($nodesArray); + } + + /** + * @param array $data + * @return JsonResponse + */ + protected function createSerializedResponse(array $data): JsonResponse + { + return new JsonResponse( + $this->serializer->serialize( + $data, + 'json', + SerializationContext::create()->setGroups([ + 'document_display', + 'explorer_thumbnail', + 'model' + ]) + ), + 200, + [], + true + ); + } +} diff --git a/lib/Rozier/src/AjaxControllers/AjaxSearchNodesSourcesController.php b/lib/Rozier/src/AjaxControllers/AjaxSearchNodesSourcesController.php new file mode 100644 index 00000000..3757ab14 --- /dev/null +++ b/lib/Rozier/src/AjaxControllers/AjaxSearchNodesSourcesController.php @@ -0,0 +1,124 @@ +documentUrlGenerator = $documentUrlGenerator; + } + + /** + * Handle AJAX edition requests for Node + * such as coming from nodetree widgets. + * + * @param Request $request + * + * @return Response JSON response + */ + public function searchAction(Request $request) + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_NODES'); + + if (!$request->query->has('searchTerms') || $request->query->get('searchTerms') == '') { + throw new BadRequestHttpException('searchTerms parameter is missing.'); + } + + $searchHandler = new GlobalNodeSourceSearchHandler($this->em()); + $searchHandler->setDisplayNonPublishedNodes(true); + + /** @var array $nodesSources */ + $nodesSources = $searchHandler->getNodeSourcesBySearchTerm( + $request->get('searchTerms'), + static::RESULT_COUNT + ); + + if (null !== $nodesSources && count($nodesSources) > 0) { + $responseArray = [ + 'statusCode' => Response::HTTP_OK, + 'status' => 'success', + 'data' => [], + 'responseText' => count($nodesSources) . ' results found.', + ]; + + foreach ($nodesSources as $source) { + if ( + $source instanceof NodesSources && + !key_exists($source->getNode()->getId(), $responseArray['data']) + ) { + $responseArray['data'][$source->getNode()->getId()] = $this->getNodeSourceData($source); + } + } + /* + * Only display one nodeSource + */ + $responseArray['data'] = array_values($responseArray['data']); + + return new JsonResponse( + $responseArray + ); + } + + return new JsonResponse([ + 'statusCode' => Response::HTTP_OK, + 'status' => 'success', + 'data' => [], + 'responseText' => 'No results found.', + ]); + } + + protected function getNodeSourceData(NodesSources $source): array + { + $thumbnail = null; + /** @var Translation $translation */ + $translation = $source->getTranslation(); + $displayableNSDoc = $source->getDocumentsByFields()->filter(function (NodesSourcesDocuments $nsDoc) { + return $nsDoc->getDocument()->isImage() || $nsDoc->getDocument()->isSvg(); + })->first(); + if ($displayableNSDoc instanceof NodesSourcesDocuments) { + $thumbnail = $displayableNSDoc->getDocument(); + $this->documentUrlGenerator->setDocument($thumbnail); + $this->documentUrlGenerator->setOptions([ + "fit" => "60x60", + "quality" => 80 + ]); + } + return [ + 'title' => $source->getTitle() ?? $source->getNode()->getNodeName(), + 'parent' => $source->getParent() ? + $source->getParent()->getTitle() ?? $source->getParent()->getNode()->getNodeName() : + null, + 'thumbnail' => $thumbnail ? $this->documentUrlGenerator->getUrl() : null, + 'nodeId' => $source->getNode()->getId(), + 'translationId' => $translation->getId(), + 'typeName' => $source->getNode()->getNodeType()->getLabel(), + 'typeColor' => $source->getNode()->getNodeType()->getColor(), + 'url' => $this->generateUrl( + 'nodesEditSourcePage', + [ + 'nodeId' => $source->getNode()->getId(), + 'translationId' => $translation->getId(), + ] + ), + ]; + } +} diff --git a/lib/Rozier/src/AjaxControllers/AjaxSessionMessages.php b/lib/Rozier/src/AjaxControllers/AjaxSessionMessages.php new file mode 100644 index 00000000..8c64e972 --- /dev/null +++ b/lib/Rozier/src/AjaxControllers/AjaxSessionMessages.php @@ -0,0 +1,38 @@ +denyAccessUnlessGranted('ROLE_BACKEND_USER'); + + $responseArray = [ + 'statusCode' => Response::HTTP_OK, + 'status' => 'success' + ]; + + if ($request->hasPreviousSession()) { + $session = $request->getSession(); + if ($session instanceof Session) { + $responseArray['messages'] = $session->getFlashBag()->all(); + } + } + return new JsonResponse( + $responseArray + ); + } +} diff --git a/lib/Rozier/src/AjaxControllers/AjaxTagTreeController.php b/lib/Rozier/src/AjaxControllers/AjaxTagTreeController.php new file mode 100644 index 00000000..304a1c90 --- /dev/null +++ b/lib/Rozier/src/AjaxControllers/AjaxTagTreeController.php @@ -0,0 +1,82 @@ +treeWidgetFactory = $treeWidgetFactory; + } + + /** + * @param Request $request + * + * @return JsonResponse + * @throws \Twig\Error\LoaderError + * @throws \Twig\Error\RuntimeError + * @throws \Twig\Error\SyntaxError + */ + public function getTreeAction(Request $request) + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_TAGS'); + + /** @var TagTreeWidget|null $tagTree */ + $tagTree = null; + + switch ($request->get("_action")) { + /* + * Inner tag edit for tagTree + */ + case 'requestTagTree': + if ($request->get('parentTagId') > 0) { + $tag = $this->em() + ->find( + Tag::class, + (int) $request->get('parentTagId') + ); + } else { + $tag = null; + } + + $tagTree = $this->treeWidgetFactory->createTagTree($tag); + + $this->assignation['mainTagTree'] = false; + + break; + /* + * Main panel tree tagTree + */ + case 'requestMainTagTree': + $parent = null; + $tagTree = $this->treeWidgetFactory->createTagTree($parent); + $this->assignation['mainTagTree'] = true; + break; + } + + $this->assignation['tagTree'] = $tagTree; + + $responseArray = [ + 'statusCode' => '200', + 'status' => 'success', + 'tagTree' => $this->getTwig()->render('@RoadizRozier/widgets/tagTree/tagTree.html.twig', $this->assignation), + ]; + + return new JsonResponse( + $responseArray + ); + } +} diff --git a/lib/Rozier/src/AjaxControllers/AjaxTagsController.php b/lib/Rozier/src/AjaxControllers/AjaxTagsController.php new file mode 100644 index 00000000..6b4b0a91 --- /dev/null +++ b/lib/Rozier/src/AjaxControllers/AjaxTagsController.php @@ -0,0 +1,389 @@ +handlerFactory = $handlerFactory; + $this->urlGenerator = $urlGenerator; + } + + /** + * @return TagRepository + */ + protected function getRepository() + { + return $this->em()->getRepository(Tag::class); + } + + /** + * @param Request $request + * + * @return Response JSON response + */ + public function indexAction(Request $request): Response + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_TAGS'); + $onlyParents = false; + + if ( + $request->query->has('onlyParents') && + $request->query->get('onlyParents') + ) { + $onlyParents = true; + } + + if ($onlyParents) { + $tags = $this->getRepository()->findByParentWithChildrenAndDefaultTranslation(); + } else { + $tags = $this->getRepository()->findByParentWithDefaultTranslation(); + } + + $responseArray = [ + 'status' => 'confirm', + 'statusCode' => 200, + 'tags' => $this->recurseTags($tags, $onlyParents), + ]; + + return new JsonResponse( + $responseArray + ); + } + + /** + * Get a Tag list from an array of node id. + * + * @param Request $request + * @return JsonResponse + */ + public function listArrayAction(Request $request): JsonResponse + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_TAGS'); + + if (!$request->query->has('ids')) { + throw new InvalidParameterException('Ids should be provided within an array'); + } + + $cleanTagIds = array_filter($request->query->filter('ids', [], \FILTER_DEFAULT, [ + 'flags' => \FILTER_FORCE_ARRAY + ])); + $normalizedTags = []; + + if (count($cleanTagIds)) { + $tags = $this->getRepository()->findBy([ + 'id' => $cleanTagIds, + ]); + + // Sort array by ids given in request + $tags = $this->sortIsh($tags, $cleanTagIds); + $normalizedTags = $this->normalizeTags($tags); + } + + $responseArray = [ + 'status' => 'confirm', + 'statusCode' => 200, + 'tags' => $normalizedTags + ]; + + return new JsonResponse( + $responseArray + ); + } + + /** + * @param Request $request + * + * @return Response JSON response + */ + public function explorerListAction(Request $request): Response + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_TAGS'); + + $arrayFilter = [ + 'translation' => $this->em()->getRepository(Translation::class)->findDefault() + ]; + $defaultOrder = [ + 'createdAt' => 'DESC' + ]; + + if ($request->get('tagId') > 0) { + $parentTag = $this->em() + ->find( + Tag::class, + $request->get('tagId') + ); + + $arrayFilter['parent'] = $parentTag; + } + + if ($request->query->has('onlyParents')) { + $arrayFilter['children'] = ['NOT NULL']; + } + + /* + * Manage get request to filter list + */ + $listManager = $this->createEntityListManager( + Tag::class, + $arrayFilter, + $defaultOrder + ); + $listManager->setDisplayingNotPublishedNodes(true); + $listManager->setItemPerPage(30); + $listManager->handle(); + + $tags = $listManager->getEntities(); + + $responseArray = [ + 'status' => 'confirm', + 'statusCode' => 200, + 'tags' => $this->normalizeTags($tags), + 'filters' => $listManager->getAssignation(), + ]; + + return new JsonResponse( + $responseArray + ); + } + + /** + * @param array|\Traversable|null $tags + * @return array + */ + protected function normalizeTags($tags): array + { + $tagsArray = []; + if ($tags !== null) { + foreach ($tags as $tag) { + $tagModel = new TagModel($tag, $this->urlGenerator); + $tagsArray[] = $tagModel->toArray(); + } + } + + return $tagsArray; + } + + /** + * @param Tag[]|null $tags + * @param bool $onlyParents + * + * @return array + */ + protected function recurseTags(array $tags = null, bool $onlyParents = false): array + { + $tagsArray = []; + if ($tags !== null) { + foreach ($tags as $tag) { + if ($onlyParents) { + $children = $this->getRepository()->findByParentWithChildrenAndDefaultTranslation($tag); + } else { + $children = $this->getRepository()->findByParentWithDefaultTranslation($tag); + } + + $tagsArray[] = [ + 'id' => $tag->getId(), + 'name' => $tag->getTranslatedTags()->first() ? $tag->getTranslatedTags()->first()->getName() : $tag->getTagName(), + 'children' => $this->recurseTags($children, $onlyParents), + ]; + } + } + + return $tagsArray; + } + + /** + * Handle AJAX edition requests for Tag + * such as coming from tag-tree widgets. + * + * @param Request $request + * @param int $tagId + * + * @return JsonResponse + */ + public function editAction(Request $request, int $tagId): JsonResponse + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_TAGS'); + + $tag = $this->em()->find(Tag::class, (int) $tagId); + + if ($tag !== null) { + /* + * Get the right update method against "_action" parameter + */ + switch ($request->get('_action')) { + case 'updatePosition': + $this->updatePosition($request->request->all(), $tag); + break; + } + + return new JsonResponse( + [ + 'statusCode' => '200', + 'status' => 'success', + 'responseText' => ('Tag ' . $tagId . ' edited '), + ], + Response::HTTP_PARTIAL_CONTENT + ); + } + + throw $this->createNotFoundException('Tag ' . $tagId . ' does not exists'); + } + + /** + * @param Request $request + * + * @return JsonResponse + */ + public function searchAction(Request $request): JsonResponse + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_TAGS'); + + if ($request->get('search') != "") { + $responseArray = []; + + $pattern = strip_tags($request->get('search')); + + $tags = $this->getRepository() + ->searchBy($pattern, [], [], 10); + + if (0 === count($tags)) { + /* + * Try again using tag slug + */ + $pattern = StringHandler::slugify($pattern); + $tags = $this->getRepository() + ->searchBy($pattern, [], [], 10); + } + + if (count($tags) > 0) { + /** @var Tag $tag */ + foreach ($tags as $tag) { + $responseArray[] = $tag->getFullPath(); + } + + return new JsonResponse( + $responseArray + ); + } else { + throw $this->createNotFoundException('No tags found.'); + } + } + + throw new BadRequestHttpException('Search is empty.'); + } + + /** + * @param array $parameters + * @param Tag $tag + */ + protected function updatePosition($parameters, Tag $tag): void + { + /* + * First, we set the new parent + */ + $parent = null; + + if ( + !empty($parameters['newParent']) && + $parameters['newParent'] > 0 + ) { + $parent = $this->em() + ->find(Tag::class, (int) $parameters['newParent']); + + if ($parent !== null) { + $tag->setParent($parent); + } + } else { + $tag->setParent(null); + } + + /* + * Then compute new position + */ + if ( + !empty($parameters['nextTagId']) && + $parameters['nextTagId'] > 0 + ) { + $nextTag = $this->em()->find(Tag::class, (int) $parameters['nextTagId']); + if ($nextTag !== null) { + $tag->setPosition($nextTag->getPosition() - 0.5); + } + } elseif ( + !empty($parameters['prevTagId']) && + $parameters['prevTagId'] > 0 + ) { + $prevTag = $this->em()->find(Tag::class, (int) $parameters['prevTagId']); + if ($prevTag !== null) { + $tag->setPosition($prevTag->getPosition() + 0.5); + } + } + // Apply position update before cleaning + $this->em()->flush(); + + /** @var TagHandler $tagHandler */ + $tagHandler = $this->handlerFactory->getHandler($tag); + $tagHandler->cleanPositions(); + + $this->em()->flush(); + + /* + * Dispatch event + */ + $this->dispatchEvent(new TagUpdatedEvent($tag)); + } + + /** + * Create a new Tag. + * + * @param Request $request + * @return JsonResponse + */ + public function createAction(Request $request): JsonResponse + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_TAGS'); + + if (!$request->get('tagName')) { + throw new InvalidParameterException('tagName should be provided to create a new Tag'); + } + + if ($request->getMethod() != Request::METHOD_POST) { + throw new BadRequestHttpException(); + } + + /** @var Tag $tag */ + $tag = $this->getRepository()->findOrCreateByPath($request->get('tagName')); + $tagModel = new TagModel($tag, $this->urlGenerator); + + return new JsonResponse( + [ + 'tag' => $tagModel->toArray() + ], + Response::HTTP_CREATED + ); + } +} diff --git a/lib/Rozier/src/Controllers/AbstractAdminController.php b/lib/Rozier/src/Controllers/AbstractAdminController.php new file mode 100644 index 00000000..f27a505a --- /dev/null +++ b/lib/Rozier/src/Controllers/AbstractAdminController.php @@ -0,0 +1,540 @@ +serializer = $serializer; + $this->urlGenerator = $urlGenerator; + } + + /** + * @return string + */ + protected function getThemeDirectory(): string + { + return RozierApp::getThemeDir(); + } + + /** + * @return string + */ + protected function getTemplateNamespace(): string + { + return '@RoadizRozier'; + } + + protected function additionalAssignation(Request $request): void + { + $this->assignation['controller_namespace'] = $this->getNamespace(); + } + + protected function prepareWorkingItem(PersistableInterface $item): void + { + // Add or modify current working item. + } + + /** + * @param Request $request + * @return Response|null + * @throws \Twig\Error\RuntimeError + */ + public function defaultAction(Request $request) + { + $this->denyAccessUnlessGranted($this->getRequiredRole()); + $this->additionalAssignation($request); + + $elm = $this->createEntityListManager( + $this->getEntityClass(), + $this->getDefaultCriteria($request), + $this->getDefaultOrder($request) + ); + $elm->setDisplayingNotPublishedNodes(true); + /* + * Stored item per pages in session + */ + $sessionListFilter = new SessionListFilters($this->getNamespace() . '_item_per_page'); + $sessionListFilter->handleItemPerPage($request, $elm); + $elm->handle(); + + $this->assignation['items'] = $elm->getEntities(); + $this->assignation['filters'] = $elm->getAssignation(); + + return $this->render( + $this->getTemplateFolder() . '/list.html.twig', + $this->assignation, + null, + $this->getTemplateNamespace() + ); + } + + /** + * @param Request $request + * @return RedirectResponse|Response|null + * @throws \Twig\Error\RuntimeError + */ + public function addAction(Request $request) + { + $this->denyAccessUnlessGranted($this->getRequiredRole()); + $this->additionalAssignation($request); + + $item = $this->createEmptyItem($request); + $this->prepareWorkingItem($item); + $form = $this->createForm($this->getFormTypeFromRequest($request), $item); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + /* + * Events are dispatched before entity manager is flushed + * to be able to throw exceptions before it is persisted. + */ + $event = $this->createCreateEvent($item); + $this->dispatchSingleOrMultipleEvent($event); + + $this->em()->persist($item); + $this->em()->flush(); + + $postEvent = $this->createPostCreateEvent($item); + $this->dispatchSingleOrMultipleEvent($postEvent); + + $msg = $this->getTranslator()->trans( + '%namespace%.%item%.was_created', + [ + '%item%' => $this->getEntityName($item), + '%namespace%' => $this->getTranslator()->trans($this->getNamespace()) + ] + ); + $this->publishConfirmMessage($request, $msg); + + return $this->getPostSubmitResponse($item, true, $request); + } + + $this->assignation['form'] = $form->createView(); + $this->assignation['item'] = $item; + + return $this->render( + $this->getTemplateFolder() . '/add.html.twig', + $this->assignation, + null, + $this->getTemplateNamespace() + ); + } + + /** + * @param Request $request + * @param int|string $id Numeric ID or UUID + * @return Response|null + * @throws \Twig\Error\RuntimeError + */ + public function editAction(Request $request, $id) + { + $this->denyAccessUnlessGranted($this->getRequiredRole()); + $this->additionalAssignation($request); + + /** @var mixed|object|null $item */ + $item = $this->em()->find($this->getEntityClass(), $id); + if (!($item instanceof PersistableInterface)) { + throw $this->createNotFoundException(); + } + + $this->prepareWorkingItem($item); + $this->denyAccessUnlessItemGranted($item); + + $form = $this->createForm($this->getFormTypeFromRequest($request), $item); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + /* + * Events are dispatched before entity manager is flushed + * to be able to throw exceptions before it is persisted. + */ + $event = $this->createUpdateEvent($item); + $this->dispatchSingleOrMultipleEvent($event); + $this->em()->flush(); + + /* + * Event that requires that EM is flushed + */ + $postEvent = $this->createPostUpdateEvent($item); + $this->dispatchSingleOrMultipleEvent($postEvent); + + $msg = $this->getTranslator()->trans( + '%namespace%.%item%.was_updated', + [ + '%item%' => $this->getEntityName($item), + '%namespace%' => $this->getTranslator()->trans($this->getNamespace()) + ] + ); + $this->publishConfirmMessage($request, $msg); + + return $this->getPostSubmitResponse($item, false, $request); + } + + $this->assignation['form'] = $form->createView(); + $this->assignation['item'] = $item; + + return $this->render( + $this->getTemplateFolder() . '/edit.html.twig', + $this->assignation, + null, + $this->getTemplateNamespace() + ); + } + + public function exportAction(Request $request): JsonResponse + { + $this->denyAccessUnlessGranted($this->getRequiredRole()); + $this->additionalAssignation($request); + + $items = $this->em()->getRepository($this->getEntityClass())->findAll(); + + return new JsonResponse( + $this->serializer->serialize( + $items, + 'json', + SerializationContext::create()->setGroups([$this->getNamespace()]) + ), + Response::HTTP_OK, + [ + 'Content-Disposition' => sprintf( + 'attachment; filename="%s_%s.json"', + $this->getNamespace(), + (new \DateTime())->format('YmdHi') + ), + ], + true + ); + } + + /** + * @param Request $request + * @param int|string $id Numeric ID or UUID + * @return RedirectResponse|Response|null + * @throws \Twig\Error\RuntimeError + */ + public function deleteAction(Request $request, $id) + { + $this->denyAccessUnlessGranted($this->getRequiredDeletionRole()); + $this->additionalAssignation($request); + + /** @var mixed|object|null $item */ + $item = $this->em()->find($this->getEntityClass(), $id); + + if (!($item instanceof PersistableInterface)) { + throw $this->createNotFoundException(); + } + + $this->prepareWorkingItem($item); + $this->denyAccessUnlessItemGranted($item); + + $form = $this->createForm(FormType::class); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + /* + * Events are dispatched before entity manager is flushed + * to be able to throw exceptions before it is persisted. + */ + $event = $this->createDeleteEvent($item); + $this->dispatchSingleOrMultipleEvent($event); + $this->em()->remove($item); + $this->em()->flush(); + + $postEvent = $this->createPostDeleteEvent($item); + $this->dispatchSingleOrMultipleEvent($postEvent); + + $msg = $this->getTranslator()->trans( + '%namespace%.%item%.was_deleted', + [ + '%item%' => $this->getEntityName($item), + '%namespace%' => $this->getTranslator()->trans($this->getNamespace()) + ] + ); + $this->publishConfirmMessage($request, $msg); + + return $this->getPostDeleteResponse($item); + } + + $this->assignation['form'] = $form->createView(); + $this->assignation['item'] = $item; + + return $this->render( + $this->getTemplateFolder() . '/delete.html.twig', + $this->assignation, + null, + $this->getTemplateNamespace() + ); + } + + /** + * @param PersistableInterface $item + * @return bool + */ + abstract protected function supports(PersistableInterface $item): bool; + + /** + * @return string Namespace is used for composing messages and translations. + */ + abstract protected function getNamespace(): string; + + /** + * @param Request $request + * @return PersistableInterface + */ + abstract protected function createEmptyItem(Request $request): PersistableInterface; + + /** + * @return string + */ + abstract protected function getTemplateFolder(): string; + + /** + * @return string + */ + abstract protected function getRequiredRole(): string; + + /** + * @return string + */ + protected function getRequiredDeletionRole(): string + { + return $this->getRequiredRole(); + } + + /** + * @return class-string + */ + abstract protected function getEntityClass(): string; + + /** + * @return class-string + */ + abstract protected function getFormType(): string; + + /** + * @param Request $request + * @return class-string + */ + protected function getFormTypeFromRequest(Request $request): string + { + /* + * Routing can define defaults._type to change edition Form dynamically. + */ + if (null !== $type = $request->attributes->get('_type')) { + if (!class_exists($type)) { + throw new InvalidConfigurationException(\sprintf('Route uses non-existent %s form type class.', $type)); + } + return (string) $type; + } + + // Falls back on child-class implemented form type. + return $this->getFormType(); + } + + /** + * @param Request $request + * @return array + */ + protected function getDefaultCriteria(Request $request): array + { + return []; + } + + /** + * @param Request $request + * @return array + */ + protected function getDefaultOrder(Request $request): array + { + return []; + } + + /** + * @return string + */ + abstract protected function getDefaultRouteName(): string; + + /** + * @return string + */ + abstract protected function getEditRouteName(): string; + + /** + * @param PersistableInterface $item + * @param bool $forceDefaultEditRoute + * @param Request|null $request + * @return Response + */ + protected function getPostSubmitResponse( + PersistableInterface $item, + bool $forceDefaultEditRoute = false, + ?Request $request = null + ): Response { + /* + * Force redirect to avoid resending form when refreshing page + */ + if ( + null !== $request && $request->query->has('referer') && + (new UnicodeString($request->query->get('referer')))->startsWith('/') + ) { + return $this->redirect($request->query->get('referer')); + } + + /* + * Try to redirect to same route as defined in Request attribute + */ + if ( + false === $forceDefaultEditRoute && + null !== $request && + null !== $route = $request->attributes->get('_route') + ) { + return $this->redirect($this->urlGenerator->generate( + $route, + $this->getEditRouteParameters($item) + )); + } + + return $this->redirect($this->urlGenerator->generate( + $this->getEditRouteName(), + $this->getEditRouteParameters($item) + )); + } + + /** + * @param PersistableInterface $item + * @return array + */ + protected function getEditRouteParameters(PersistableInterface $item): array + { + return [ + 'id' => $item->getId() + ]; + } + + /** + * @param PersistableInterface $item + * @return Response + */ + protected function getPostDeleteResponse(PersistableInterface $item): Response + { + return $this->redirect($this->urlGenerator->generate($this->getDefaultRouteName())); + } + + /** + * @param Event|Event[]|mixed|null $event + * @return object|object[]|null + */ + protected function dispatchSingleOrMultipleEvent($event) + { + if (null === $event) { + return null; + } + if ($event instanceof Event) { + return $this->dispatchEvent($event); + } + if (is_iterable($event)) { + $events = []; + foreach ($event as $singleEvent) { + $events[] = $this->dispatchSingleOrMultipleEvent($singleEvent); + } + return $events; + } + throw new \InvalidArgumentException('Event must be null, Event or array of Event'); + } + + /** + * @param PersistableInterface $item + * @return Event|Event[]|null + */ + protected function createCreateEvent(PersistableInterface $item) + { + return null; + } + + /** + * @param PersistableInterface $item + * @return Event|Event[]|null + */ + protected function createPostCreateEvent(PersistableInterface $item) + { + return null; + } + + /** + * @param PersistableInterface $item + * @return Event|Event[]|null + */ + protected function createUpdateEvent(PersistableInterface $item) + { + return null; + } + + /** + * @param PersistableInterface $item + * @return Event|Event[]|null + */ + protected function createPostUpdateEvent(PersistableInterface $item) + { + return null; + } + + /** + * @param PersistableInterface $item + * @return Event|Event[]|null + */ + protected function createDeleteEvent(PersistableInterface $item) + { + return null; + } + + /** + * @param PersistableInterface $item + * @return Event|Event[]|null + */ + protected function createPostDeleteEvent(PersistableInterface $item) + { + return null; + } + + /** + * @param PersistableInterface $item + * @return string + */ + abstract protected function getEntityName(PersistableInterface $item): string; + + /** + * @param PersistableInterface $item + */ + protected function denyAccessUnlessItemGranted(PersistableInterface $item): void + { + // Do nothing + } +} diff --git a/lib/Rozier/src/Controllers/Attributes/AttributeController.php b/lib/Rozier/src/Controllers/Attributes/AttributeController.php new file mode 100644 index 00000000..dcfe7c0d --- /dev/null +++ b/lib/Rozier/src/Controllers/Attributes/AttributeController.php @@ -0,0 +1,165 @@ +attributeImporter = $attributeImporter; + } + + + /** + * @inheritDoc + */ + protected function supports(PersistableInterface $item): bool + { + return $item instanceof Attribute; + } + + /** + * @inheritDoc + */ + protected function getNamespace(): string + { + return 'attribute'; + } + + /** + * @inheritDoc + */ + protected function createEmptyItem(Request $request): PersistableInterface + { + $item = new Attribute(); + $item->setCode('new_attribute'); + return $item; + } + + /** + * @inheritDoc + */ + protected function getTemplateFolder(): string + { + return '@RoadizRozier/attributes'; + } + + /** + * @inheritDoc + */ + protected function getRequiredRole(): string + { + return 'ROLE_ACCESS_ATTRIBUTES'; + } + + /** + * @inheritDoc + */ + protected function getRequiredDeletionRole(): string + { + return 'ROLE_ACCESS_ATTRIBUTES_DELETE'; + } + + + /** + * @inheritDoc + */ + protected function getEntityClass(): string + { + return Attribute::class; + } + + /** + * @inheritDoc + */ + protected function getFormType(): string + { + return AttributeType::class; + } + + /** + * @inheritDoc + */ + protected function getDefaultOrder(Request $request): array + { + return ['code' => 'ASC']; + } + + /** + * @inheritDoc + */ + protected function getDefaultRouteName(): string + { + return 'attributesHomePage'; + } + + /** + * @inheritDoc + */ + protected function getEditRouteName(): string + { + return 'attributesEditPage'; + } + + /** + * @inheritDoc + */ + protected function getEntityName(PersistableInterface $item): string + { + if ($item instanceof Attribute) { + return $item->getCode(); + } + throw new \InvalidArgumentException('Item should be instance of ' . $this->getEntityClass()); + } + + /** + * @param Request $request + * @return Response + */ + public function importAction(Request $request) + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_ATTRIBUTES'); + + $form = $this->createForm(AttributeImportType::class); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + /** @var UploadedFile $file */ + $file = $form->get('file')->getData(); + + if ($file->isValid()) { + $serializedData = file_get_contents($file->getPathname()); + + $this->attributeImporter->import($serializedData); + $this->em()->flush(); + return $this->redirectToRoute('attributesHomePage'); + } + $form->addError(new FormError($this->getTranslator()->trans('file.not_uploaded'))); + } + + $this->assignation['form'] = $form->createView(); + + return $this->render('@RoadizRozier/attributes/import.html.twig', $this->assignation); + } +} diff --git a/lib/Rozier/src/Controllers/Attributes/AttributeGroupController.php b/lib/Rozier/src/Controllers/Attributes/AttributeGroupController.php new file mode 100644 index 00000000..69eeb70f --- /dev/null +++ b/lib/Rozier/src/Controllers/Attributes/AttributeGroupController.php @@ -0,0 +1,108 @@ +getName(); + } + throw new \InvalidArgumentException('Item should be instance of ' . $this->getEntityClass()); + } + + /** + * @inheritDoc + */ + protected function getDefaultOrder(Request $request): array + { + return ['canonicalName' => 'ASC']; + } +} diff --git a/lib/Rozier/src/Controllers/CacheController.php b/lib/Rozier/src/Controllers/CacheController.php new file mode 100644 index 00000000..801e553e --- /dev/null +++ b/lib/Rozier/src/Controllers/CacheController.php @@ -0,0 +1,126 @@ +logger = $logger; + } + + public function deleteDoctrineCache(Request $request): Response + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_DOCTRINE_CACHE_DELETE'); + + $form = $this->buildDeleteDoctrineForm(); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $event = new CachePurgeRequestEvent(); + $this->dispatchEvent($event); + + $msg = $this->getTranslator()->trans('cache.deleted'); + $this->publishConfirmMessage($request, $msg); + + foreach ($event->getMessages() as $message) { + $this->logger->info(sprintf('Cache cleared: %s', $message['description'])); + } + foreach ($event->getErrors() as $message) { + $this->publishErrorMessage($request, sprintf('Could not clear cache: %s', $message['description'])); + } + + /* + * Force redirect to avoid resending form when refreshing page + */ + return $this->redirectToRoute('adminHomePage'); + } + + $this->assignation['form'] = $form->createView(); + + $this->assignation['cachesInfo'] = [ + 'resultCache' => $this->em()->getConfiguration()->getResultCacheImpl(), + 'hydratationCache' => $this->em()->getConfiguration()->getHydrationCacheImpl(), + 'queryCache' => $this->em()->getConfiguration()->getQueryCacheImpl(), + 'metadataCache' => $this->em()->getConfiguration()->getMetadataCacheImpl(), + ]; + + foreach ($this->assignation['cachesInfo'] as $key => $value) { + if (null !== $value) { + $this->assignation['cachesInfo'][$key] = get_class($value); + } else { + $this->assignation['cachesInfo'][$key] = false; + } + } + + return $this->render('@RoadizRozier/cache/deleteDoctrine.html.twig', $this->assignation); + } + + /** + * @return FormInterface + */ + private function buildDeleteDoctrineForm(): FormInterface + { + $builder = $this->createFormBuilder(); + + return $builder->getForm(); + } + + /** + * @param Request $request + * + * @return Response + * @throws \Twig\Error\RuntimeError + */ + public function deleteAssetsCache(Request $request): Response + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_DOCTRINE_CACHE_DELETE'); + + $form = $this->buildDeleteAssetsForm(); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $this->dispatchEvent(new CachePurgeAssetsRequestEvent()); + $msg = $this->getTranslator()->trans('cache.deleted'); + $this->publishConfirmMessage($request, $msg); + + /* + * Force redirect to avoid resending form when refreshing page + */ + return $this->redirectToRoute('adminHomePage'); + } + + $this->assignation['form'] = $form->createView(); + + return $this->render('@RoadizRozier/cache/deleteAssets.html.twig', $this->assignation); + } + + /** + * @return FormInterface + */ + private function buildDeleteAssetsForm(): FormInterface + { + $builder = $this->createFormBuilder(); + + return $builder->getForm(); + } +} diff --git a/lib/Rozier/src/Controllers/CustomForms/CustomFormAnswersController.php b/lib/Rozier/src/Controllers/CustomForms/CustomFormAnswersController.php new file mode 100644 index 00000000..092bf1fb --- /dev/null +++ b/lib/Rozier/src/Controllers/CustomForms/CustomFormAnswersController.php @@ -0,0 +1,122 @@ +denyAccessUnlessGranted('ROLE_ACCESS_CUSTOMFORMS'); + /* + * Manage get request to filter list + */ + + $customForm = $this->em()->find( + CustomForm::class, + $customFormId + ); + + $listManager = $this->createEntityListManager( + CustomFormAnswer::class, + ["customForm" => $customForm], + ["submittedAt" => "DESC"] + ); + $listManager->setDisplayingNotPublishedNodes(true); + $listManager->handle(); + $this->assignation['customForm'] = $customForm; + $this->assignation['filters'] = $listManager->getAssignation(); + $this->assignation['custom_form_answers'] = $listManager->getEntities(); + + return $this->render('@RoadizRozier/custom-form-answers/list.html.twig', $this->assignation); + } + + /** + * Return an deletion form for requested node-type. + * + * @param Request $request + * @param int $customFormAnswerId + * + * @return Response + */ + public function deleteAction(Request $request, int $customFormAnswerId) + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_CUSTOMFORMS_DELETE'); + + $customFormAnswer = $this->em()->find(CustomFormAnswer::class, $customFormAnswerId); + + if (null !== $customFormAnswer) { + $this->assignation['customFormAnswer'] = $customFormAnswer; + + $form = $this->buildDeleteForm($customFormAnswer); + + $form->handleRequest($request); + + if ( + $form->isSubmitted() && + $form->isValid() && + $form->getData()['customFormAnswerId'] == $customFormAnswer->getId() + ) { + $this->em()->remove($customFormAnswer); + + $msg = $this->getTranslator()->trans('customFormAnswer.%id%.deleted', ['%id%' => $customFormAnswer->getId()]); + $this->publishConfirmMessage($request, $msg); + /* + * Redirect to update schema page + */ + return $this->redirectToRoute( + 'customFormAnswersHomePage', + ["customFormId" => $customFormAnswer->getCustomForm()->getId()] + ); + } + + $this->assignation['form'] = $form->createView(); + + return $this->render('@RoadizRozier/custom-form-answers/delete.html.twig', $this->assignation); + } + + throw new ResourceNotFoundException(); + } + + /** + * @param CustomFormAnswer $customFormAnswer + * + * @return FormInterface + */ + private function buildDeleteForm(CustomFormAnswer $customFormAnswer) + { + $builder = $this->createFormBuilder() + ->add('customFormAnswerId', HiddenType::class, [ + 'data' => $customFormAnswer->getId(), + 'constraints' => [ + new NotNull(), + new NotBlank(), + ], + ]); + + return $builder->getForm(); + } +} diff --git a/lib/Rozier/src/Controllers/CustomForms/CustomFormFieldAttributesController.php b/lib/Rozier/src/Controllers/CustomForms/CustomFormFieldAttributesController.php new file mode 100644 index 00000000..01835e4f --- /dev/null +++ b/lib/Rozier/src/Controllers/CustomForms/CustomFormFieldAttributesController.php @@ -0,0 +1,67 @@ +denyAccessUnlessGranted('ROLE_ACCESS_CUSTOMFORMS'); + /* + * Manage get request to filter list + */ + + /** @var CustomFormAnswer $customFormAnswer */ + $customFormAnswer = $this->em()->find(CustomFormAnswer::class, $customFormAnswerId); + $answers = $this->getAnswersByGroups($customFormAnswer->getAnswers()); + + $this->assignation['fields'] = $answers; + $this->assignation['answer'] = $customFormAnswer; + $this->assignation['customFormId'] = $customFormAnswer->getCustomForm()->getId(); + + return $this->render('@RoadizRozier/custom-form-field-attributes/list.html.twig', $this->assignation); + } + + /** + * @param Collection|array $answers + * @return array + */ + protected function getAnswersByGroups($answers) + { + $fieldsArray = []; + + /** @var CustomFormFieldAttribute $answer */ + foreach ($answers as $answer) { + $groupName = $answer->getCustomFormField()->getGroupName(); + if ($groupName != '') { + if (!isset($fieldsArray[$groupName])) { + $fieldsArray[$groupName] = []; + } + $fieldsArray[$groupName][] = $answer; + } else { + $fieldsArray[] = $answer; + } + } + + return $fieldsArray; + } +} diff --git a/lib/Rozier/src/Controllers/CustomForms/CustomFormFieldsController.php b/lib/Rozier/src/Controllers/CustomForms/CustomFormFieldsController.php new file mode 100644 index 00000000..d0a203bd --- /dev/null +++ b/lib/Rozier/src/Controllers/CustomForms/CustomFormFieldsController.php @@ -0,0 +1,238 @@ +denyAccessUnlessGranted('ROLE_ACCESS_CUSTOMFORMS'); + + $customForm = $this->em()->find(CustomForm::class, $customFormId); + + if ($customForm !== null) { + $fields = $customForm->getFields(); + + $this->assignation['customForm'] = $customForm; + $this->assignation['fields'] = $fields; + + return $this->render('@RoadizRozier/custom-form-fields/list.html.twig', $this->assignation); + } + + throw new ResourceNotFoundException(); + } + + /** + * Return an edition form for requested node-type. + * + * @param Request $request + * @param int $customFormFieldId + * + * @return Response + */ + public function editAction(Request $request, int $customFormFieldId) + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_CUSTOMFORMS'); + + /** @var CustomFormField|null $field */ + $field = $this->em()->find(CustomFormField::class, $customFormFieldId); + + if ($field !== null) { + $this->assignation['customForm'] = $field->getCustomForm(); + $this->assignation['field'] = $field; + $form = $this->createForm(CustomFormFieldType::class, $field); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $this->em()->flush(); + + $msg = $this->getTranslator()->trans('customFormField.%name%.updated', ['%name%' => $field->getName()]); + $this->publishConfirmMessage($request, $msg); + + /* + * Redirect to update schema page + */ + return $this->redirectToRoute( + 'customFormFieldsListPage', + [ + 'customFormId' => $field->getCustomForm()->getId(), + ] + ); + } + + $this->assignation['form'] = $form->createView(); + + return $this->render('@RoadizRozier/custom-form-fields/edit.html.twig', $this->assignation); + } + + throw new ResourceNotFoundException(); + } + + /** + * Return an creation form for requested node-type. + * + * @param Request $request + * @param int $customFormId + * + * @return Response + */ + public function addAction(Request $request, int $customFormId) + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_CUSTOMFORMS'); + + $field = new CustomFormField(); + $customForm = $this->em()->find(CustomForm::class, $customFormId); + $field->setCustomForm($customForm); + + if ( + $customForm !== null && + $field !== null + ) { + $this->assignation['customForm'] = $customForm; + $this->assignation['field'] = $field; + $form = $this->createForm(CustomFormFieldType::class, $field); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + try { + $this->em()->persist($field); + $this->em()->flush(); + + $msg = $this->getTranslator()->trans( + 'customFormField.%name%.created', + ['%name%' => $field->getName()] + ); + $this->publishConfirmMessage($request, $msg); + + /* + * Redirect to update schema page + */ + return $this->redirectToRoute( + 'customFormFieldsListPage', + [ + 'customFormId' => $customFormId, + ] + ); + } catch (Exception $e) { + $msg = $e->getMessage(); + $this->publishErrorMessage($request, $msg); + /* + * Redirect to add page + */ + return $this->redirectToRoute( + 'customFormFieldsAddPage', + ['customFormId' => $customFormId] + ); + } + } + + $this->assignation['form'] = $form->createView(); + + return $this->render('@RoadizRozier/custom-form-fields/add.html.twig', $this->assignation); + } + + throw new ResourceNotFoundException(); + } + + /** + * Return a deletion form for requested node. + * + * @param Request $request + * @param int $customFormFieldId + * + * @return Response + */ + public function deleteAction(Request $request, int $customFormFieldId) + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_CUSTOMFORMS_DELETE'); + + $field = $this->em()->find(CustomFormField::class, $customFormFieldId); + + if ($field !== null) { + $this->assignation['field'] = $field; + $form = $this->buildDeleteForm($field); + $form->handleRequest($request); + + if ( + $form->isSubmitted() && + $form->isValid() && + $form->getData()['customFormFieldId'] == $field->getId() + ) { + $customFormId = $field->getCustomForm()->getId(); + + $this->em()->remove($field); + $this->em()->flush(); + + /* + * Update Database + */ + $msg = $this->getTranslator()->trans( + 'customFormField.%name%.deleted', + ['%name%' => $field->getName()] + ); + $this->publishConfirmMessage($request, $msg); + + /* + * Redirect to update schema page + */ + return $this->redirectToRoute( + 'customFormFieldsListPage', + [ + 'customFormId' => $customFormId, + ] + ); + } + + $this->assignation['form'] = $form->createView(); + + return $this->render('@RoadizRozier/custom-form-fields/delete.html.twig', $this->assignation); + } + + throw new ResourceNotFoundException(); + } + + /** + * @param CustomFormField $field + * + * @return FormInterface + */ + private function buildDeleteForm(CustomFormField $field) + { + $builder = $this->createFormBuilder() + ->add('customFormFieldId', HiddenType::class, [ + 'data' => $field->getId(), + 'constraints' => [ + new NotNull(), + new NotBlank(), + ], + ]); + + return $builder->getForm(); + } +} diff --git a/lib/Rozier/src/Controllers/CustomForms/CustomFormsController.php b/lib/Rozier/src/Controllers/CustomForms/CustomFormsController.php new file mode 100644 index 00000000..7956bec1 --- /dev/null +++ b/lib/Rozier/src/Controllers/CustomForms/CustomFormsController.php @@ -0,0 +1,108 @@ + 'DESC']; + } + + /** + * @inheritDoc + */ + protected function getDefaultRouteName(): string + { + return 'customFormsHomePage'; + } + + /** + * @inheritDoc + */ + protected function getEditRouteName(): string + { + return 'customFormsEditPage'; + } + + /** + * @inheritDoc + */ + protected function getEntityName(PersistableInterface $item): string + { + if ($item instanceof CustomForm) { + return $item->getName(); + } + throw new \InvalidArgumentException('Item should be instance of ' . $this->getEntityClass()); + } +} diff --git a/lib/Rozier/src/Controllers/CustomForms/CustomFormsUtilsController.php b/lib/Rozier/src/Controllers/CustomForms/CustomFormsUtilsController.php new file mode 100644 index 00000000..fc014c56 --- /dev/null +++ b/lib/Rozier/src/Controllers/CustomForms/CustomFormsUtilsController.php @@ -0,0 +1,144 @@ +customFormAnswerSerializer = $customFormAnswerSerializer; + } + + /** + * Export all custom form's answer in a Xlsx file (.rzt). + * + * @param Request $request + * @param int $id + * + * @return Response + */ + public function exportAction(Request $request, int $id) + { + /** @var CustomForm|null $customForm */ + $customForm = $this->em()->find(CustomForm::class, $id); + if (null === $customForm) { + throw $this->createNotFoundException(); + } + + $answers = $customForm->getCustomFormAnswers(); + + /** + * @var int $key + * @var CustomFormAnswer $answer + */ + foreach ($answers as $key => $answer) { + $array = array_merge( + [$answer->getIp(), $answer->getSubmittedAt()], + $this->customFormAnswerSerializer->toSimpleArray($answer) + ); + $answers[$key] = $array; + } + + $keys = ["ip", "submitted.date"]; + + $fields = $customForm->getFieldsLabels(); + $keys = array_merge($keys, $fields); + + $exporter = new XlsxExporter($this->getTranslator()); + $xlsx = $exporter->exportXlsx($answers, $keys); + + $response = new Response( + $xlsx, + Response::HTTP_OK, + [] + ); + + $response->headers->set( + 'Content-Disposition', + $response->headers->makeDisposition( + ResponseHeaderBag::DISPOSITION_ATTACHMENT, + $customForm->getName() . '.xlsx' + ) + ); + + $response->prepare($request); + + return $response; + } + + /** + * Duplicate custom form by ID + * + * @param Request $request + * @param int $id + * + * @return Response + */ + public function duplicateAction(Request $request, int $id) + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_CUSTOMFORMS'); + /** @var CustomForm|null $existingCustomForm */ + $existingCustomForm = $this->em()->find(CustomForm::class, $id); + + if (null === $existingCustomForm) { + throw $this->createNotFoundException(); + } + + try { + $newCustomForm = clone $existingCustomForm; + $newCustomForm->setCreatedAt(new \DateTime()); + $newCustomForm->setUpdatedAt(new \DateTime()); + $em = $this->em(); + + foreach ($newCustomForm->getFields() as $field) { + $em->persist($field); + } + + $em->persist($newCustomForm); + $em->flush(); + + $msg = $this->getTranslator()->trans("duplicated.custom.form.%name%", [ + '%name%' => $existingCustomForm->getDisplayName(), + ]); + + $this->publishConfirmMessage($request, $msg); + + return $this->redirectToRoute( + 'customFormsEditPage', + ["id" => $newCustomForm->getId()] + ); + } catch (\Exception $e) { + $this->publishErrorMessage( + $request, + $this->getTranslator()->trans("impossible.duplicate.custom.form.%name%", [ + '%name%' => $existingCustomForm->getDisplayName(), + ]) + ); + $this->publishErrorMessage($request, $e->getMessage()); + + return $this->redirectToRoute( + 'customFormsEditPage', + ["id" => $existingCustomForm->getId()] + ); + } + } +} diff --git a/lib/Rozier/src/Controllers/DashboardController.php b/lib/Rozier/src/Controllers/DashboardController.php new file mode 100644 index 00000000..81178887 --- /dev/null +++ b/lib/Rozier/src/Controllers/DashboardController.php @@ -0,0 +1,37 @@ +denyAccessUnlessGranted('ROLE_BACKEND_USER'); + + $this->assignation['latestLogs'] = []; + + $this->assignation['latestLogs'] = $this->em() + ->getRepository(Log::class) + ->findLatestByNodesSources(8); + + + return $this->render('@RoadizRozier/dashboard/index.html.twig', $this->assignation); + } +} diff --git a/lib/Rozier/src/Controllers/Documents/DocumentTranslationsController.php b/lib/Rozier/src/Controllers/Documents/DocumentTranslationsController.php new file mode 100644 index 00000000..70e81f4d --- /dev/null +++ b/lib/Rozier/src/Controllers/Documents/DocumentTranslationsController.php @@ -0,0 +1,272 @@ +denyAccessUnlessGranted('ROLE_ACCESS_DOCUMENTS'); + + if (null === $translationId) { + $translation = $this->em()->getRepository(Translation::class)->findDefault(); + if ($translation instanceof PersistableInterface) { + $translationId = $translation->getId(); + } + } else { + $translation = $this->em()->find(Translation::class, $translationId); + } + + $this->assignation['available_translations'] = $this->em() + ->getRepository(Translation::class) + ->findAll(); + + /** @var Document $document */ + $document = $this->em() + ->find(Document::class, $documentId); + $documentTr = $this->em() + ->getRepository(DocumentTranslation::class) + ->findOneBy(['document' => $documentId, 'translation' => $translationId]); + + if ($documentTr === null && $document !== null && $translation !== null) { + $documentTr = $this->createDocumentTranslation($document, $translation); + } + + if ($documentTr !== null && $document !== null) { + $this->assignation['document'] = $document; + $this->assignation['translation'] = $translation; + $this->assignation['documentTr'] = $documentTr; + + /** + * Versioning + */ + if ($this->isGranted('ROLE_ACCESS_VERSIONS')) { + if (null !== $response = $this->handleVersions($request, $documentTr)) { + return $response; + } + } + + /* + * Handle main form + */ + $form = $this->createForm(DocumentTranslationType::class, $documentTr, [ + 'referer' => $this->getRequest()->get('referer'), + 'disabled' => $this->isReadOnly, + ]); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $this->onPostUpdate($documentTr, $request); + + $routeParams = [ + 'documentId' => $document->getId(), + 'translationId' => $translationId, + ]; + + if ($form->get('referer')->getData()) { + $routeParams = array_merge($routeParams, [ + 'referer' => $form->get('referer')->getData() + ]); + } + + /* + * Force redirect to avoid resending form when refreshing page + */ + return $this->redirectToRoute( + 'documentsMetaPage', + $routeParams + ); + } + + $this->assignation['form'] = $form->createView(); + $this->assignation['readOnly'] = $this->isReadOnly; + + return $this->render('@RoadizRozier/document-translations/edit.html.twig', $this->assignation); + } + + throw new ResourceNotFoundException(); + } + + /** + * @param Document $document + * @param TranslationInterface $translation + * + * @return DocumentTranslation + */ + protected function createDocumentTranslation(Document $document, TranslationInterface $translation) + { + $dt = new DocumentTranslation(); + $dt->setDocument($document); + $dt->setTranslation($translation); + + $this->em()->persist($dt); + + return $dt; + } + + /** + * Return an deletion form for requested document. + * + * @param Request $request + * @param int $documentId + * @param int $translationId + * + * @return Response + * @throws RuntimeError + */ + public function deleteAction(Request $request, int $documentId, int $translationId) + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_DOCUMENTS_DELETE'); + + $documentTr = $this->em() + ->getRepository(DocumentTranslation::class) + ->findOneBy(['document' => $documentId, 'translation' => $translationId]); + $document = $this->em() + ->find(Document::class, $documentId); + + if ( + $documentTr !== null && + $document !== null + ) { + $this->assignation['documentTr'] = $documentTr; + $this->assignation['document'] = $document; + $form = $this->buildDeleteForm($documentTr); + $form->handleRequest($request); + + if ( + $form->isSubmitted() && + $form->isValid() && + $form->getData()['documentId'] == $documentTr->getId() + ) { + try { + $this->em()->remove($documentTr); + $this->em()->flush(); + + $msg = $this->getTranslator()->trans( + 'document.translation.%name%.deleted', + ['%name%' => (string) $document] + ); + $this->publishConfirmMessage($request, $msg); + } catch (Exception $e) { + $msg = $this->getTranslator()->trans( + 'document.translation.%name%.cannot_delete', + ['%name%' => (string) $document] + ); + $this->publishErrorMessage($request, $msg); + } + /* + * Force redirect to avoid resending form when refreshing page + */ + return $this->redirectToRoute( + 'documentsEditPage', + ['documentId' => $document->getId()] + ); + } + + $this->assignation['form'] = $form->createView(); + + return $this->render('@RoadizRozier/document-translations/delete.html.twig', $this->assignation); + } + + throw new ResourceNotFoundException(); + } + + /** + * @param DocumentTranslation $doc + * + * @return \Symfony\Component\Form\FormInterface + */ + private function buildDeleteForm(DocumentTranslation $doc) + { + $defaults = [ + 'documentTranslationId' => $doc->getId(), + ]; + $builder = $this->createFormBuilder($defaults) + ->add('documentTranslationId', HiddenType::class, [ + 'data' => $doc->getId(), + 'constraints' => [ + new NotNull(), + new NotBlank(), + ], + ]); + + return $builder->getForm(); + } + + protected function onPostUpdate(PersistableInterface $entity, Request $request): void + { + /* + * Dispatch pre-flush event + */ + if ($entity instanceof DocumentTranslation) { + $this->dispatchEvent( + new DocumentTranslationUpdatedEvent($entity->getDocument(), $entity) + ); + $this->em()->flush(); + $msg = $this->getTranslator()->trans('document.translation.%name%.updated', [ + '%name%' => (string) $entity->getDocument(), + ]); + $this->publishConfirmMessage($request, $msg); + } + } + + /** + * @param PersistableInterface $entity + * + * @return Response + */ + protected function getPostUpdateRedirection(PersistableInterface $entity): ?Response + { + if ( + $entity instanceof DocumentTranslation && + $entity->getDocument() instanceof Document && + $entity->getTranslation() instanceof Translation + ) { + $routeParams = [ + 'documentId' => $entity->getDocument()->getId(), + 'translationId' => $entity->getTranslation()->getId(), + ]; + /* + * Force redirect to avoid resending form when refreshing page + */ + return $this->redirectToRoute( + 'documentsMetaPage', + $routeParams + ); + } + return null; + } +} diff --git a/lib/Rozier/src/Controllers/Documents/DocumentsController.php b/lib/Rozier/src/Controllers/Documents/DocumentsController.php new file mode 100644 index 00000000..322d336b --- /dev/null +++ b/lib/Rozier/src/Controllers/Documents/DocumentsController.php @@ -0,0 +1,1119 @@ + 50, + 'fit' => '128x128', + 'sharpen' => 5, + 'inline' => false, + 'picture' => true, + 'loading' => 'lazy', + ]; + private EmbedFinderFactory $embedFinderFactory; + + public function __construct( + array $documentPlatforms, + FilesystemOperator $documentsStorage, + HandlerFactoryInterface $handlerFactory, + LoggerInterface $logger, + RandomImageFinder $randomImageFinder, + DocumentFactory $documentFactory, + RendererInterface $renderer, + DocumentUrlGeneratorInterface $documentUrlGenerator, + UrlGeneratorInterface $urlGenerator, + EmbedFinderFactory $embedFinderFactory, + ?string $googleServerId = null, + ?string $soundcloudClientId = null + ) { + $this->documentPlatforms = $documentPlatforms; + $this->handlerFactory = $handlerFactory; + $this->logger = $logger; + $this->randomImageFinder = $randomImageFinder; + $this->documentFactory = $documentFactory; + $this->renderer = $renderer; + $this->documentUrlGenerator = $documentUrlGenerator; + $this->urlGenerator = $urlGenerator; + $this->googleServerId = $googleServerId; + $this->soundcloudClientId = $soundcloudClientId; + $this->documentsStorage = $documentsStorage; + $this->embedFinderFactory = $embedFinderFactory; + } + + /** + * @param Request $request + * @param int|null $folderId + * @return Response + * @throws RuntimeError + */ + public function indexAction(Request $request, ?int $folderId = null): Response + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_DOCUMENTS'); + + /** @var Translation $translation */ + $translation = $this->em() + ->getRepository(Translation::class) + ->findDefault(); + + $prefilters = [ + 'raw' => false, + ]; + + if ( + null !== $folderId && + $folderId > 0 + ) { + $folder = $this->em() + ->find(Folder::class, $folderId); + + $prefilters['folders'] = [$folder]; + $this->assignation['folder'] = $folder; + } + + if ( + $request->query->has('type') && + $request->query->get('type', '') !== '' + ) { + $prefilters['mimeType'] = trim($request->query->get('type', '')); + $this->assignation['mimeType'] = trim($request->query->get('type', '')); + } + + if ( + $request->query->has('embedPlatform') && + $request->query->get('embedPlatform', '') !== '' + ) { + $prefilters['embedPlatform'] = trim($request->query->get('embedPlatform', '')); + $this->assignation['embedPlatform'] = trim($request->query->get('embedPlatform', '')); + } + $this->assignation['availablePlatforms'] = $this->documentPlatforms; + + /* + * Handle bulk folder form + */ + $joinFolderForm = $this->buildLinkFoldersForm(); + $joinFolderForm->handleRequest($request); + if ($joinFolderForm->isSubmitted() && $joinFolderForm->isValid()) { + $data = $joinFolderForm->getData(); + $submitFolder = $joinFolderForm->get('submitFolder'); + $submitUnfolder = $joinFolderForm->get('submitUnfolder'); + if ($submitFolder instanceof ClickableInterface && $submitFolder->isClicked()) { + $msg = $this->joinFolder($data); + } elseif ($submitUnfolder instanceof ClickableInterface && $submitUnfolder->isClicked()) { + $msg = $this->leaveFolder($data); + } else { + $msg = $this->getTranslator()->trans('wrong.request'); + } + + $this->publishConfirmMessage($request, $msg); + + return $this->redirectToRoute( + 'documentsHomePage', + ['folderId' => $folderId] + ); + } + $this->assignation['joinFolderForm'] = $joinFolderForm->createView(); + + /* + * Manage get request to filter list + */ + $listManager = $this->createEntityListManager( + Document::class, + $prefilters, + ['createdAt' => 'DESC'] + ); + $listManager->setDisplayingNotPublishedNodes(true); + $listManager->setItemPerPage(static::DEFAULT_ITEM_PER_PAGE); + + /* + * Stored in session + */ + $sessionListFilter = new SessionListFilters('documents_item_per_page'); + $sessionListFilter->handleItemPerPage($request, $listManager); + + $listManager->handle(); + + $this->assignation['filters'] = $listManager->getAssignation(); + $this->assignation['documents'] = $listManager->getEntities(); + $this->assignation['translation'] = $translation; + $this->assignation['thumbnailFormat'] = $this->thumbnailFormat; + + return $this->render($this->getListingTemplate($request), $this->assignation); + } + + /** + * @param Request $request + * @param int $documentId + * @return Response + * @throws RuntimeError + * @throws FilesystemException + */ + public function adjustAction(Request $request, int $documentId): Response + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_DOCUMENTS'); + + /** @var Document|null $document */ + $document = $this->em()->find(Document::class, $documentId); + if ($document === null) { + throw new ResourceNotFoundException(); + } + if (!$document->isLocal()) { + throw new ResourceNotFoundException('Document is not local'); + } + + // Assign document + $this->assignation['document'] = $document; + + // Build form and handle it + $fileForm = $this->buildFileForm(); + $fileForm->handleRequest($request); + + // Check if form is valid + if ($fileForm->isSubmitted() && $fileForm->isValid()) { + $em = $this->em(); + + if (null !== $document->getRawDocument()) { + $cloneDocument = clone $document; + + // need to remove raw document BEFORE + // setting it to cloned document + $rawDocument = $document->getRawDocument(); + $document->setRawDocument(null); + $em->flush(); + + $cloneDocument->setRawDocument($rawDocument); + $oldPath = $cloneDocument->getMountPath(); + + /* + * Prefix document filename with unique id to avoid overriding original + * if already existing. + */ + $cloneDocument->setFilename('original_' . uniqid() . '_' . $cloneDocument); + $newPath = $cloneDocument->getMountPath(); + + $this->documentsStorage->move($oldPath, $newPath); + + $em->persist($cloneDocument); + $em->flush(); + } + + /** @var UploadedFile $uploadedFile */ + $uploadedFile = $fileForm->get('editDocument')->getData(); + $this->documentFactory->setFile($uploadedFile); + $this->documentFactory->updateDocument($document); + $em->flush(); + + // Event must be dispatched AFTER flush for async concurrency matters + $this->dispatchEvent( + new DocumentFileUpdatedEvent($document) + ); + // Event must be dispatched AFTER flush for async concurrency matters + $this->dispatchEvent( + new DocumentUpdatedEvent($document) + ); + + $translator = $this->getTranslator(); + $msg = $translator->trans('document.%name%.updated', [ + '%name%' => (string) $document, + ]); + + return new JsonResponse([ + 'message' => $msg, + 'path' => $this->documentsStorage->publicUrl($document->getMountPath()) . '?' . \random_int(10, 999), + ]); + } + + // Create form view and assign it + $this->assignation['file_form'] = $fileForm->createView(); + + return $this->render('@RoadizRozier/documents/adjust.html.twig', $this->assignation); + } + + /** + * @param Request $request + * @param int $documentId + * @return Response + * @throws FilesystemException + * @throws RuntimeError + */ + public function editAction(Request $request, int $documentId): Response + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_DOCUMENTS'); + + /** @var Document|null $document */ + $document = $this->em()->find(Document::class, $documentId); + if ($document === null) { + throw new ResourceNotFoundException(); + } + + $form = $this->createForm(DocumentEditType::class, $document, [ + 'referer' => $this->getRequest()->get('referer'), + 'document_platforms' => $this->documentPlatforms, + ]); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + try { + $this->em()->flush(); + /* + * Update document file + * if present + */ + if (null !== $newDocumentFile = $form->get('newDocument')->getData()) { + $this->documentFactory->setFile($newDocumentFile); + $this->documentFactory->updateDocument($document); + $msg = $this->getTranslator()->trans('document.file.%name%.updated', [ + '%name%' => (string) $document, + ]); + $this->em()->flush(); + // Event must be dispatched AFTER flush for async concurrency matters + $this->dispatchEvent( + new DocumentFileUpdatedEvent($document) + ); + $this->publishConfirmMessage($request, $msg); + } + + $msg = $this->getTranslator()->trans('document.%name%.updated', [ + '%name%' => (string) $document, + ]); + $this->publishConfirmMessage($request, $msg); + $this->em()->flush(); + // Event must be dispatched AFTER flush for async concurrency matters + $this->dispatchEvent( + new DocumentUpdatedEvent($document) + ); + $this->em()->flush(); + + $routeParams = ['documentId' => $document->getId()]; + + if ($form->get('referer')->getData()) { + $routeParams = array_merge($routeParams, [ + 'referer' => $form->get('referer')->getData(), + ]); + } + /* + * Force redirect to avoid resending form when refreshing page + */ + return $this->redirectToRoute( + 'documentsEditPage', + $routeParams + ); + } catch (FileException $exception) { + $form->get('filename')->addError(new FormError($exception->getMessage())); + } + } + + $this->assignation['document'] = $document; + $this->assignation['rawDocument'] = $document->getRawDocument(); + $this->assignation['form'] = $form->createView(); + + return $this->render('@RoadizRozier/documents/edit.html.twig', $this->assignation); + } + + /** + * Return an deletion form for requested document. + * + * @param Request $request + * @param int $documentId + * @return Response + * @throws RuntimeError + */ + public function deleteAction(Request $request, int $documentId): Response + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_DOCUMENTS_DELETE'); + + /** @var Document|null $document */ + $document = $this->em()->find(Document::class, $documentId); + + if ($document === null) { + throw new ResourceNotFoundException(); + } + + $this->assignation['document'] = $document; + $form = $this->buildDeleteForm($document); + $form->handleRequest($request); + + if ( + $form->isSubmitted() && + $form->isValid() && + $form->getData()['documentId'] == $document->getId() + ) { + try { + $this->dispatchEvent( + new DocumentDeletedEvent($document) + ); + $this->em()->remove($document); + $this->em()->flush(); + $msg = $this->getTranslator()->trans('document.%name%.deleted', [ + '%name%' => (string) $document + ]); + $this->publishConfirmMessage($request, $msg); + } catch (\Exception $e) { + $msg = $this->getTranslator()->trans('document.%name%.cannot_delete', [ + '%name%' => (string) $document + ]); + $this->logger->error($e->getMessage()); + $this->publishErrorMessage($request, $msg); + } + /* + * Force redirect to avoid resending form when refreshing page + */ + return $this->redirectToRoute('documentsHomePage'); + } + + $this->assignation['form'] = $form->createView(); + + return $this->render('@RoadizRozier/documents/delete.html.twig', $this->assignation); + } + + /** + * Return an deletion form for multiple docs. + * + * @param Request $request + * @return Response + * @throws RuntimeError + */ + public function bulkDeleteAction(Request $request): Response + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_DOCUMENTS_DELETE'); + + $documentsIds = $request->get('documents', []); + if (count($documentsIds) <= 0) { + throw new ResourceNotFoundException('No selected documents to delete.'); + } + + /** @var array $documents */ + $documents = $this->em() + ->getRepository(Document::class) + ->findBy([ + 'id' => $documentsIds, + ]); + + if (count($documents) <= 0) { + throw new ResourceNotFoundException(); + } + + $this->assignation['documents'] = $documents; + $form = $this->buildBulkDeleteForm($documentsIds); + + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + foreach ($documents as $document) { + $this->em()->remove($document); + $msg = $this->getTranslator()->trans( + 'document.%name%.deleted', + ['%name%' => (string) $document] + ); + $this->publishConfirmMessage($request, $msg); + } + $this->em()->flush(); + + return $this->redirectToRoute('documentsHomePage'); + } + $this->assignation['form'] = $form->createView(); + $this->assignation['action'] = '?' . http_build_query(['documents' => $documentsIds]); + $this->assignation['thumbnailFormat'] = $this->thumbnailFormat; + + return $this->render('@RoadizRozier/documents/bulkDelete.html.twig', $this->assignation); + } + + /** + * Embed external document page. + * + * @param Request $request + * @param int|null $folderId + * @return Response + * @throws RuntimeError + */ + public function embedAction(Request $request, ?int $folderId = null): Response + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_DOCUMENTS'); + + if (null !== $folderId && $folderId > 0) { + $folder = $this->em()->find(Folder::class, $folderId); + + $this->assignation['folder'] = $folder; + } + + /* + * Handle main form + */ + $form = $this->createForm(DocumentEmbedType::class, null, [ + 'document_platforms' => $this->documentPlatforms, + ]); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + try { + $document = $this->embedDocument($form->getData(), $folderId); + + if (is_iterable($document)) { + foreach ($document as $singleDocument) { + $msg = $this->getTranslator()->trans('document.%name%.uploaded', [ + '%name%' => (string) $singleDocument, + ]); + $this->publishConfirmMessage($request, $msg); + $this->dispatchEvent( + new DocumentCreatedEvent($singleDocument) + ); + } + } else { + $msg = $this->getTranslator()->trans('document.%name%.uploaded', [ + '%name%' => (string) $document, + ]); + $this->publishConfirmMessage($request, $msg); + $this->dispatchEvent( + new DocumentCreatedEvent($document) + ); + } + /* + * Force redirect to avoid resending form when refreshing page + */ + return $this->redirectToRoute('documentsHomePage', ['folderId' => $folderId]); + } catch (RequestException $e) { + $this->logger->error($e->getRequest()->getUri() . ' failed.'); + if (null !== $e->getResponse() && in_array($e->getResponse()->getStatusCode(), [401, 403, 404])) { + $form->addError(new FormError( + $this->getTranslator()->trans('document.media_not_found_or_private') + )); + } else { + $form->addError(new FormError($this->getTranslator()->trans($e->getMessage()))); + } + } catch (APINeedsAuthentificationException $e) { + $form->addError(new FormError($this->getTranslator()->trans($e->getMessage()))); + } catch (\RuntimeException $e) { + $form->addError(new FormError($this->getTranslator()->trans($e->getMessage()))); + } catch (\InvalidArgumentException $e) { + $form->addError(new FormError($this->getTranslator()->trans($e->getMessage()))); + } + } + + $this->assignation['form'] = $form->createView(); + + return $this->render('@RoadizRozier/documents/embed.html.twig', $this->assignation); + } + + /** + * Get random external document page. + * + * @param Request $request + * @param int|null $folderId + * @return Response + * @throws FilesystemException + */ + public function randomAction(Request $request, ?int $folderId = null): Response + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_DOCUMENTS'); + + try { + $document = $this->randomDocument($folderId); + + $msg = $this->getTranslator()->trans('document.%name%.uploaded', [ + '%name%' => (string) $document, + ]); + $this->publishConfirmMessage($request, $msg); + + $this->dispatchEvent( + new DocumentCreatedEvent($document) + ); + } catch (\Exception $e) { + $this->publishErrorMessage( + $request, + $this->getTranslator()->trans($e->getMessage()) + ); + } + /* + * Force redirect to avoid resending form when refreshing page + */ + return $this->redirectToRoute('documentsHomePage', ['folderId' => $folderId]); + } + + /** + * Download document file. + * + * @param Request $request + * @param int $documentId + * @return Response + * @throws FilesystemException + */ + public function downloadAction(Request $request, int $documentId): Response + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_DOCUMENTS'); + + /** @var Document|null $document */ + $document = $this->em()->find(Document::class, $documentId); + + if ($document !== null) { + /** @var DocumentHandler $handler */ + $handler = $this->handlerFactory->getHandler($document); + + return $handler->getDownloadResponse(); + } + + throw new ResourceNotFoundException(); + } + + /** + * @param Request $request + * @param int|null $folderId + * @param string $_format + * @return Response + * @throws RuntimeError + */ + public function uploadAction(Request $request, ?int $folderId = null, string $_format = 'html'): Response + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_DOCUMENTS'); + + if (null !== $folderId && $folderId > 0) { + $folder = $this->em()->find(Folder::class, $folderId); + + $this->assignation['folder'] = $folder; + } + + /* + * Handle main form + */ + $form = $this->buildUploadForm($folderId); + $form->handleRequest($request); + + if ($form->isSubmitted()) { + if ($form->isValid()) { + $document = $this->uploadDocument($form, $folderId); + + if (null !== $document) { + $msg = $this->getTranslator()->trans('document.%name%.uploaded', [ + '%name%' => (string) $document, + ]); + $this->publishConfirmMessage($request, $msg); + + // Event must be dispatched AFTER flush for async concurrency matters + $this->dispatchEvent( + new DocumentCreatedEvent($document) + ); + + if ($_format === 'json' || $request->isXmlHttpRequest()) { + $documentModel = new DocumentModel( + $document, + $this->renderer, + $this->documentUrlGenerator, + $this->urlGenerator, + $this->embedFinderFactory + ); + return new JsonResponse([ + 'success' => true, + 'document' => $documentModel->toArray(), + ], JsonResponse::HTTP_CREATED); + } else { + return $this->redirectToRoute('documentsHomePage', ['folderId' => $folderId]); + } + } else { + $msg = $this->getTranslator()->trans('document.cannot_persist'); + $this->publishErrorMessage($request, $msg); + + if ($_format === 'json' || $request->isXmlHttpRequest()) { + throw $this->createNotFoundException($msg); + } else { + return $this->redirectToRoute('documentsHomePage', ['folderId' => $folderId]); + } + } + } elseif ($_format === 'json' || $request->isXmlHttpRequest()) { + /* + * Bad form submitted + */ + $errorPerForm = []; + /** @var Form $child */ + foreach ($form as $child) { + if ($child->isSubmitted() && !$child->isValid()) { + foreach ($child->getErrors() as $error) { + $errorPerForm[$child->getName()][] = $this->getTranslator()->trans($error->getMessage()); + } + } + } + return new JsonResponse( + [ + "errors" => $errorPerForm, + ], + Response::HTTP_BAD_REQUEST + ); + } + } + $this->assignation['form'] = $form->createView(); + $this->assignation['maxUploadSize'] = UploadedFile::getMaxFilesize() / 1024 / 1024; + + return $this->render('@RoadizRozier/documents/upload.html.twig', $this->assignation); + } + + /** + * Return a node list using this document. + * + * @param Request $request + * @param int $documentId + * @return Response + * @throws RuntimeError + */ + public function usageAction(Request $request, int $documentId): Response + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_DOCUMENTS'); + /** @var Document|null $document */ + $document = $this->em()->find(Document::class, $documentId); + + if ($document === null) { + throw new ResourceNotFoundException(); + } + + $this->assignation['document'] = $document; + $this->assignation['usages'] = $document->getNodesSourcesByFields(); + $this->assignation['attributes'] = $document->getAttributeDocuments() + ->map(function (AttributeDocuments $attributeDocument) { + return $attributeDocument->getAttribute(); + }); + $this->assignation['tags'] = $document->getTagTranslations() + ->map(function (TagTranslationDocuments $tagTranslationDocuments) { + return $tagTranslationDocuments->getTagTranslation()->getTag(); + }); + + return $this->render('@RoadizRozier/documents/usage.html.twig', $this->assignation); + } + + /** + * @param Document $doc + * @return FormInterface + */ + private function buildDeleteForm(Document $doc): FormInterface + { + $defaults = [ + 'documentId' => $doc->getId(), + ]; + $builder = $this->createFormBuilder($defaults) + ->add('documentId', HiddenType::class, [ + 'data' => $doc->getId(), + 'constraints' => [ + new NotNull(), + new NotBlank(), + ], + ]); + + return $builder->getForm(); + } + + /** + * @param array $documentsIds + * @return FormInterface + */ + private function buildBulkDeleteForm(array $documentsIds): FormInterface + { + $defaults = [ + 'checksum' => md5(serialize($documentsIds)), + ]; + $builder = $this->createFormBuilder($defaults, [ + 'action' => '?' . http_build_query(['documents' => $documentsIds]), + ]) + ->add('checksum', HiddenType::class, [ + 'constraints' => [ + new NotNull(), + new NotBlank(), + ], + ]); + + return $builder->getForm(); + } + + /** + * @return FormInterface + */ + private function buildFileForm(): FormInterface + { + $defaults = [ + 'editDocument' => null, + ]; + + $builder = $this->createFormBuilder($defaults) + ->add('editDocument', FileType::class, [ + 'label' => 'overwrite.document', + 'required' => false, + 'constraints' => [ + new File(), + ], + ]); + + return $builder->getForm(); + } + + /** + * @param int|null $folderId + * @return FormInterface + */ + private function buildUploadForm(?int $folderId = null): FormInterface + { + $builder = $this->createFormBuilder([], [ + 'csrf_protection' => false, + ]) + ->add('attachment', FileType::class, [ + 'label' => 'choose.documents.to_upload', + 'constraints' => [ + new File(), + ], + ]); + + if ( + null !== $folderId && + $folderId > 0 + ) { + $builder->add('folderId', HiddenType::class, [ + 'data' => $folderId, + ]); + } + + return $builder->getForm(); + } + + /** + * @return FormInterface + */ + private function buildLinkFoldersForm(): FormInterface + { + $builder = $this->createNamedFormBuilder('folderForm') + ->add('documentsId', HiddenType::class, [ + 'attr' => ['class' => 'document-id-bulk-folder'], + 'constraints' => [ + new NotNull(), + new NotBlank(), + ], + ]) + ->add('folderPaths', TextType::class, [ + 'label' => false, + 'attr' => [ + 'class' => 'rz-folder-autocomplete', + 'placeholder' => 'list.folders.to_link', + ], + 'constraints' => [ + new NotNull(), + new NotBlank(), + ], + ]) + ->add('submitFolder', SubmitType::class, [ + 'label' => false, + 'attr' => [ + 'class' => 'uk-button uk-button-primary', + 'title' => 'link.folders', + 'data-uk-tooltip' => "{animation:true}", + ], + ]) + ->add('submitUnfolder', SubmitType::class, [ + 'label' => false, + 'attr' => [ + 'class' => 'uk-button', + 'title' => 'unlink.folders', + 'data-uk-tooltip' => "{animation:true}", + ], + ]); + + return $builder->getForm(); + } + + /** + * @param array $data + * @return string Status message + */ + private function joinFolder($data): string + { + $msg = $this->getTranslator()->trans('no_documents.linked_to.folders'); + + if ( + !empty($data['documentsId']) && + !empty($data['folderPaths']) + ) { + $documentsIds = explode(',', $data['documentsId']); + + $documents = $this->em() + ->getRepository(Document::class) + ->findBy([ + 'id' => $documentsIds, + ]); + + $folderPaths = explode(',', $data['folderPaths']); + $folderPaths = array_filter($folderPaths); + + foreach ($folderPaths as $path) { + /** @var Folder $folder */ + $folder = $this->em() + ->getRepository(Folder::class) + ->findOrCreateByPath($path); + + /* + * Add each selected documents + */ + foreach ($documents as $document) { + $folder->addDocument($document); + } + } + + $this->em()->flush(); + $msg = $this->getTranslator()->trans('documents.linked_to.folders'); + + /* + * Dispatch events + */ + foreach ($documents as $document) { + $this->dispatchEvent( + new DocumentInFolderEvent($document) + ); + } + } + + return $msg; + } + + /** + * @param array $data + * @return string Status message + */ + private function leaveFolder($data): string + { + $msg = $this->getTranslator()->trans('no_documents.removed_from.folders'); + + if ( + !empty($data['documentsId']) && + !empty($data['folderPaths']) + ) { + $documentsIds = explode(',', $data['documentsId']); + + $documents = $this->em() + ->getRepository(Document::class) + ->findBy([ + 'id' => $documentsIds, + ]); + + $folderPaths = explode(',', $data['folderPaths']); + $folderPaths = array_filter($folderPaths); + + foreach ($folderPaths as $path) { + /** @var Folder $folder */ + $folder = $this->em() + ->getRepository(Folder::class) + ->findByPath($path); + + if (null !== $folder) { + /* + * Add each selected documents + */ + foreach ($documents as $document) { + $folder->removeDocument($document); + } + } + } + $this->em()->flush(); + $msg = $this->getTranslator()->trans('documents.removed_from.folders'); + + /* + * Dispatch events + */ + foreach ($documents as $document) { + $this->dispatchEvent( + new DocumentOutFolderEvent($document) + ); + } + } + + return $msg; + } + + /** + * @param array $data + * @param int|null $folderId + * + * @return DocumentInterface|array + * @throws \Exception + * @throws EntityAlreadyExistsException + */ + private function embedDocument($data, ?int $folderId = null) + { + $handlers = $this->documentPlatforms; + + if ( + isset($data['embedId']) && + isset($data['embedPlatform']) && + in_array($data['embedPlatform'], array_keys($handlers)) + ) { + $class = $handlers[$data['embedPlatform']]; + + /* + * Use empty constructor. + */ + /** @var AbstractEmbedFinder $finder */ + $finder = new $class('', false); + + if ($finder instanceof YoutubeEmbedFinder) { + $finder->setKey($this->googleServerId); + } + if ($finder instanceof SoundcloudEmbedFinder) { + $finder->setKey($this->soundcloudClientId); + } + $finder->setEmbedId($data['embedId']); + return $this->createDocumentFromFinder($finder, $folderId); + } else { + throw new \RuntimeException("bad.request", 1); + } + } + + /** + * Download a random document. + * + * @param int|null $folderId + * + * @return DocumentInterface|null + * @throws FilesystemException + */ + private function randomDocument(?int $folderId = null): ?DocumentInterface + { + if ($this->randomImageFinder instanceof AbstractEmbedFinder) { + $document = $this->createDocumentFromFinder($this->randomImageFinder, $folderId); + if ($document instanceof DocumentInterface) { + return $document; + } + if (is_array($document) && isset($document[0])) { + return $document[0]; + } + return null; + } + throw new \RuntimeException('Random image finder must be instance of ' . AbstractEmbedFinder::class); + } + + /** + * @param AbstractEmbedFinder $finder + * @param int|null $folderId + * @return DocumentInterface|array + * @throws FilesystemException + */ + private function createDocumentFromFinder(AbstractEmbedFinder $finder, ?int $folderId = null): DocumentInterface|array + { + $document = $finder->createDocumentFromFeed($this->em(), $this->documentFactory); + + if (null !== $document && null !== $folderId && $folderId > 0) { + /** @var Folder|null $folder */ + $folder = $this->em()->find(Folder::class, $folderId); + + if (is_iterable($document)) { + /** @var DocumentInterface $singleDocument */ + foreach ($document as $singleDocument) { + $singleDocument->addFolder($folder); + $folder->addDocument($singleDocument); + } + } else { + $document->addFolder($folder); + $folder->addDocument($document); + } + } + $this->em()->flush(); + + return $document; + } + + /** + * Handle upload form data to create a Document. + * + * @param FormInterface $data + * @param int|null $folderId + * @return DocumentInterface|null + * @throws FilesystemException + */ + private function uploadDocument(FormInterface $data, ?int $folderId = null): ?DocumentInterface + { + $folder = null; + if (null !== $folderId && $folderId > 0) { + /** @var Folder $folder */ + $folder = $this->em()->find(Folder::class, $folderId); + } + + if (!empty($data['attachment'])) { + $uploadedFile = $data['attachment']->getData(); + + $this->documentFactory->setFile($uploadedFile); + $this->documentFactory->setFolder($folder); + + if (null !== $document = $this->documentFactory->getDocument()) { + $this->em()->flush(); + return $document; + } + } + + return null; + } + + private function getListingTemplate(Request $request): string + { + if ($request->query->get('list') === '1') { + return '@RoadizRozier/documents/list-table.html.twig'; + } + return '@RoadizRozier/documents/list.html.twig'; + } +} diff --git a/lib/Rozier/src/Controllers/FoldersController.php b/lib/Rozier/src/Controllers/FoldersController.php new file mode 100644 index 00000000..44f671d1 --- /dev/null +++ b/lib/Rozier/src/Controllers/FoldersController.php @@ -0,0 +1,347 @@ +documentArchiver = $documentArchiver; + } + + public function indexAction(Request $request): Response + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_DOCUMENTS'); + + $listManager = $this->createEntityListManager( + Folder::class + ); + $listManager->setDisplayingNotPublishedNodes(true); + $listManager->handle(); + + $this->assignation['filters'] = $listManager->getAssignation(); + $this->assignation['folders'] = $listManager->getEntities(); + + return $this->render('@RoadizRozier/folders/list.html.twig', $this->assignation); + } + + /** + * Return a creation form for requested folder. + * + * @param Request $request + * @param int|null $parentFolderId + * + * @return Response + * @throws RuntimeError + */ + public function addAction(Request $request, ?int $parentFolderId = null): Response + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_DOCUMENTS'); + + $folder = new Folder(); + + if (null !== $parentFolderId) { + $parentFolder = $this->em()->find(Folder::class, $parentFolderId); + if (null !== $parentFolder) { + $folder->setParent($parentFolder); + } + } + $form = $this->createForm(FolderType::class, $folder); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + try { + /** @var Translation $translation */ + $translation = $this->em()->getRepository(Translation::class)->findDefault(); + $folderTranslation = new FolderTranslation($folder, $translation); + $this->em()->persist($folder); + $this->em()->persist($folderTranslation); + + $this->em()->flush(); + + $msg = $this->getTranslator()->trans( + 'folder.%name%.created', + ['%name%' => $folder->getFolderName()] + ); + $this->publishConfirmMessage($request, $msg); + + /* + * Dispatch event + */ + $this->dispatchEvent( + new FolderCreatedEvent($folder) + ); + } catch (\RuntimeException $e) { + $this->publishErrorMessage($request, $e->getMessage()); + } + + return $this->redirectToRoute('foldersHomePage'); + } + + $this->assignation['form'] = $form->createView(); + + return $this->render('@RoadizRozier/folders/add.html.twig', $this->assignation); + } + + /** + * Return a deletion form for requested folder. + * + * @param Request $request + * @param int $folderId + * + * @return Response + * @throws RuntimeError + */ + public function deleteAction(Request $request, int $folderId): Response + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_DOCUMENTS'); + + /** @var Folder|null $folder */ + $folder = $this->em()->find(Folder::class, $folderId); + + if (null === $folder || $folder->isLocked()) { + throw new ResourceNotFoundException('Folder does not exist or is locked'); + } + + $form = $this->createForm(FormType::class, $folder); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + try { + $this->em()->remove($folder); + $this->em()->flush(); + $msg = $this->getTranslator()->trans( + 'folder.%name%.deleted', + ['%name%' => $folder->getFolderName()] + ); + $this->publishConfirmMessage($request, $msg); + + /* + * Dispatch event + */ + $this->dispatchEvent( + new FolderDeletedEvent($folder) + ); + } catch (\RuntimeException $e) { + $this->publishErrorMessage($request, $e->getMessage()); + } + + return $this->redirectToRoute('foldersHomePage'); + } + + $this->assignation['form'] = $form->createView(); + $this->assignation['folder'] = $folder; + + return $this->render('@RoadizRozier/folders/delete.html.twig', $this->assignation); + } + + /** + * Return an edition form for requested folder. + * + * @param Request $request + * @param int $folderId + * + * @return Response + * @throws RuntimeError + */ + public function editAction(Request $request, int $folderId): Response + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_DOCUMENTS'); + + /** @var Folder|null $folder */ + $folder = $this->em()->find(Folder::class, $folderId); + + if ($folder === null) { + throw new ResourceNotFoundException(); + } + + /** @var Translation $translation */ + $translation = $this->em() + ->getRepository(Translation::class) + ->findDefault(); + + $form = $this->createForm(FolderType::class, $folder); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + try { + $this->em()->flush(); + $msg = $this->getTranslator()->trans( + 'folder.%name%.updated', + ['%name%' => $folder->getFolderName()] + ); + $this->publishConfirmMessage($request, $msg); + /* + * Dispatch event + */ + $this->dispatchEvent( + new FolderUpdatedEvent($folder) + ); + } catch (\RuntimeException $e) { + $this->publishErrorMessage($request, $e->getMessage()); + } + + return $this->redirectToRoute('foldersEditPage', ['folderId' => $folderId]); + } + + $this->assignation['folder'] = $folder; + $this->assignation['form'] = $form->createView(); + $this->assignation['translation'] = $translation; + + return $this->render('@RoadizRozier/folders/edit.html.twig', $this->assignation); + } + + /** + * @param Request $request + * @param int $folderId + * @param int $translationId + * + * @return Response + * @throws RuntimeError + */ + public function editTranslationAction(Request $request, int $folderId, int $translationId): Response + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_DOCUMENTS'); + + /** @var TranslationRepository $translationRepository */ + $translationRepository = $this->em()->getRepository(Translation::class); + + /** @var Folder|null $folder */ + $folder = $this->em()->find(Folder::class, $folderId); + + /** @var Translation|null $translation */ + $translation = $this->em()->find(Translation::class, $translationId); + + if (null === $folder || null === $translation) { + throw new ResourceNotFoundException(); + } + + /** @var FolderTranslation|null $folderTranslation */ + $folderTranslation = $this->em() + ->getRepository(FolderTranslation::class) + ->findOneBy([ + 'folder' => $folder, + 'translation' => $translation, + ]); + + if (null === $folderTranslation) { + $folderTranslation = new FolderTranslation($folder, $translation); + $this->em()->persist($folderTranslation); + } + + $form = $this->createForm(FolderTranslationType::class, $folderTranslation); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + try { + /* + * Update folder slug if not locked + * only from default translation. + */ + $newFolderName = StringHandler::slugify($folderTranslation->getName()); + if ($folder->getFolderName() !== $newFolderName) { + if ( + !$folder->isLocked() && + $translation->isDefaultTranslation() && + !$this->folderNameExists($newFolderName) + ) { + $folder->setFolderName($folderTranslation->getName()); + } + } + + $this->em()->flush(); + $msg = $this->getTranslator()->trans( + 'folder.%name%.updated', + ['%name%' => $folder->getFolderName()] + ); + $this->publishConfirmMessage($request, $msg); + /* + * Dispatch event + */ + $this->dispatchEvent( + new FolderUpdatedEvent($folder) + ); + } catch (\RuntimeException $e) { + $this->publishErrorMessage($request, $e->getMessage()); + } + + return $this->redirectToRoute('foldersEditTranslationPage', [ + 'folderId' => $folderId, + 'translationId' => $translationId, + ]); + } + + $this->assignation['folder'] = $folder; + $this->assignation['translation'] = $translation; + $this->assignation['form'] = $form->createView(); + $this->assignation['available_translations'] = $translationRepository->findAll(); + $this->assignation['translations'] = $translationRepository->findAvailableTranslationsForFolder($folder); + + return $this->render('@RoadizRozier/folders/edit.html.twig', $this->assignation); + } + + /** + * @param string $name + * + * @return bool + */ + protected function folderNameExists(string $name): bool + { + $entity = $this->em()->getRepository(Folder::class)->findOneByFolderName($name); + return (null !== $entity); + } + + /** + * Return a ZipArchive of requested folder. + * + * @param int $folderId + * + * @return Response + */ + public function downloadAction(int $folderId): Response + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_DOCUMENTS'); + + /** @var Folder|null $folder */ + $folder = $this->em()->find(Folder::class, $folderId); + + if ($folder === null) { + throw new ResourceNotFoundException(); + } + + $documents = $this->em() + ->getRepository(Document::class) + ->findBy([ + 'folders' => [$folder], + ]); + + return $this->documentArchiver->archiveAndServe( + $documents, + $folder->getFolderName() . '_' . date('YmdHi'), + true + ); + } +} diff --git a/lib/Rozier/src/Controllers/GroupsController.php b/lib/Rozier/src/Controllers/GroupsController.php new file mode 100644 index 00000000..61cbc768 --- /dev/null +++ b/lib/Rozier/src/Controllers/GroupsController.php @@ -0,0 +1,372 @@ +getName(); + } + throw new \InvalidArgumentException('Item should be instance of ' . $this->getEntityClass()); + } + + /** + * @inheritDoc + */ + protected function denyAccessUnlessItemGranted(PersistableInterface $item): void + { + $this->denyAccessUnlessGranted($item); + } + + /** + * Return an edition form for requested group. + * + * @param Request $request + * @param int $id + * @return Response + * @throws RuntimeError + */ + public function editRolesAction(Request $request, int $id): Response + { + $this->denyAccessUnlessGranted($this->getRequiredRole()); + + /** @var Group|null $item */ + $item = $this->em()->find($this->getEntityClass(), $id); + + if (!$item instanceof Group) { + throw $this->createNotFoundException(); + } + + $this->denyAccessUnlessItemGranted($item); + + $this->assignation['item'] = $item; + $form = $this->buildEditRolesForm($item); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $role = $this->em()->find(Role::class, (int) $form->get('roleId')->getData()); + if ($role !== null) { + $item->addRoleEntity($role); + $this->em()->flush(); + $msg = $this->getTranslator()->trans('role.%role%.linked_group.%group%', [ + '%group%' => $item->getName(), + '%role%' => $role->getRole(), + ]); + $this->publishConfirmMessage($request, $msg); + + return $this->redirectToRoute( + 'groupsEditRolesPage', + ['id' => $item->getId()] + ); + } + $form->get('roleId')->addError(new FormError('Role not found')); + } + + $this->assignation['form'] = $form->createView(); + + return $this->render('@RoadizRozier/groups/roles.html.twig', $this->assignation); + } + + /** + * @param Request $request + * @param int $id + * @param int $roleId + * + * @return Response + * @throws RuntimeError + */ + public function removeRolesAction(Request $request, int $id, int $roleId): Response + { + $this->denyAccessUnlessGranted($this->getRequiredRole()); + + /** @var Group|null $item */ + $item = $this->em()->find($this->getEntityClass(), $id); + + /** @var Role|null $role */ + $role = $this->em()->find(Role::class, $roleId); + + if (!($item instanceof Group)) { + throw $this->createNotFoundException(); + } + + if (!($role instanceof Role)) { + throw $this->createNotFoundException(); + } + + $this->denyAccessUnlessItemGranted($item); + + $this->assignation['item'] = $item; + $this->assignation['role'] = $role; + + $form = $this->createForm(FormType::class); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $item->removeRoleEntity($role); + $this->em()->flush(); + $msg = $this->getTranslator()->trans('role.%role%.removed_from_group.%group%', [ + '%role%' => $role->getRole(), + '%group%' => $item->getName(), + ]); + $this->publishConfirmMessage($request, $msg); + + return $this->redirectToRoute( + 'groupsEditRolesPage', + ['id' => $item->getId()] + ); + } + + $this->assignation['form'] = $form->createView(); + + return $this->render('@RoadizRozier/groups/removeRole.html.twig', $this->assignation); + } + + /** + * @param Request $request + * @param int $id + * + * @return Response + * @throws RuntimeError + */ + public function editUsersAction(Request $request, int $id): Response + { + $this->denyAccessUnlessGranted($this->getRequiredRole()); + + /** @var Group|null $item */ + $item = $this->em()->find($this->getEntityClass(), $id); + + if (!($item instanceof Group)) { + throw $this->createNotFoundException(); + } + + $this->denyAccessUnlessItemGranted($item); + + $this->assignation['item'] = $item; + $form = $this->buildEditUsersForm($item); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + /** @var User|null $user */ + $user = $this->em()->find(User::class, (int) $form->get('userId')->getData()); + + if ($user !== null) { + $user->addGroup($item); + $this->em()->flush(); + $msg = $this->getTranslator()->trans('user.%user%.linked.group.%group%', [ + '%group%' => $item->getName(), + '%user%' => $user->getUserName(), + ]); + $this->publishConfirmMessage($request, $msg); + + return $this->redirectToRoute( + 'groupsEditUsersPage', + ['id' => $item->getId()] + ); + } + $form->get('userId')->addError(new FormError('User not found')); + } + + $this->assignation['form'] = $form->createView(); + + return $this->render('@RoadizRozier/groups/users.html.twig', $this->assignation); + } + + /** + * @param Request $request + * @param int $id + * @param int $userId + * + * @return Response + * @throws RuntimeError + */ + public function removeUsersAction(Request $request, int $id, int $userId): Response + { + $this->denyAccessUnlessGranted($this->getRequiredRole()); + + /** @var Group|null $item */ + $item = $this->em()->find($this->getEntityClass(), $id); + /** @var User|null $user */ + $user = $this->em()->find(User::class, $userId); + + if (!($item instanceof Group)) { + throw $this->createNotFoundException(); + } + + if (null === $user) { + throw $this->createNotFoundException(); + } + + $this->denyAccessUnlessItemGranted($item); + + $this->assignation['item'] = $item; + $this->assignation['user'] = $user; + + $form = $this->createForm(FormType::class); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $user->removeGroup($item); + $this->em()->flush(); + $msg = $this->getTranslator()->trans('user.%user%.removed_from_group.%group%', [ + '%user%' => $user->getUserName(), + '%group%' => $item->getName(), + ]); + $this->publishConfirmMessage($request, $msg); + + return $this->redirectToRoute( + 'groupsEditUsersPage', + [ + 'id' => $item->getId() + ] + ); + } + + $this->assignation['form'] = $form->createView(); + + return $this->render('@RoadizRozier/groups/removeUser.html.twig', $this->assignation); + } + + /** + * @param Group $group + * + * @return FormInterface + */ + private function buildEditRolesForm(Group $group): FormInterface + { + $builder = $this->createFormBuilder() + ->add( + 'roleId', + RolesType::class, + [ + 'label' => 'choose.role', + 'roles' => $group->getRolesEntities(), + ] + ) + ; + + return $builder->getForm(); + } + + /** + * @param Group $group + * + * @return FormInterface + */ + private function buildEditUsersForm(Group $group): FormInterface + { + $builder = $this->createFormBuilder() + ->add( + 'userId', + UsersType::class, + [ + 'label' => 'choose.user', + 'constraints' => [ + new NotNull(), + new NotBlank(), + ], + 'users' => $group->getUsers(), + ] + ) + ; + + return $builder->getForm(); + } +} diff --git a/lib/Rozier/src/Controllers/GroupsUtilsController.php b/lib/Rozier/src/Controllers/GroupsUtilsController.php new file mode 100644 index 00000000..f15b6499 --- /dev/null +++ b/lib/Rozier/src/Controllers/GroupsUtilsController.php @@ -0,0 +1,159 @@ +serializer = $serializer; + $this->groupsImporter = $groupsImporter; + } + + /** + * Export all Group data and roles in a Json file (.json). + * + * @param Request $request + * + * @return Response + */ + public function exportAllAction(Request $request): Response + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_GROUPS'); + + $existingGroup = $this->em() + ->getRepository(Group::class) + ->findAll(); + + return new JsonResponse( + $this->serializer->serialize( + $existingGroup, + 'json', + SerializationContext::create()->setGroups(['group']) + ), + Response::HTTP_OK, + [ + 'Content-Disposition' => sprintf('attachment; filename="%s"', 'group-all-' . date("YmdHis") . '.json'), + ], + true + ); + } + + /** + * Export a Group in a Json file (.json). + * + * @param Request $request + * @param int $id + * + * @return Response + */ + public function exportAction(Request $request, int $id): Response + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_GROUPS'); + + $existingGroup = $this->em()->find(Group::class, $id); + + if (null === $existingGroup) { + throw $this->createNotFoundException(); + } + + return new JsonResponse( + $this->serializer->serialize( + [$existingGroup], // need to wrap in array + 'json', + SerializationContext::create()->setGroups(['group']) + ), + Response::HTTP_OK, + [ + 'Content-Disposition' => sprintf('attachment; filename="%s"', 'group-' . $existingGroup->getName() . '-' . date("YmdHis") . '.json'), + ], + true + ); + } + + /** + * Import a Json file (.rzt) containing Group datas and roles. + * + * @param Request $request + * + * @return Response + * @throws RuntimeError + */ + public function importJsonFileAction(Request $request): Response + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_GROUPS'); + + $form = $this->buildImportJsonFileForm(); + + $form->handleRequest($request); + + if ( + $form->isSubmitted() && + $form->isValid() && + !empty($form['group_file']) + ) { + /** @var UploadedFile $file */ + $file = $form['group_file']->getData(); + + if ($file->isValid()) { + $serializedData = file_get_contents($file->getPathname()); + + if (null !== \json_decode($serializedData)) { + $this->groupsImporter->import($serializedData); + $this->em()->flush(); + + $msg = $this->getTranslator()->trans('group.imported.updated'); + $this->publishConfirmMessage($request, $msg); + + // redirect even if its null + return $this->redirectToRoute( + 'groupsHomePage' + ); + } + $form->addError(new FormError($this->getTranslator()->trans('file.format.not_valid'))); + } else { + $form->addError(new FormError($this->getTranslator()->trans('file.not_uploaded'))); + } + } + + $this->assignation['form'] = $form->createView(); + + return $this->render('@RoadizRozier/groups/import.html.twig', $this->assignation); + } + + /** + * @return FormInterface + */ + private function buildImportJsonFileForm(): FormInterface + { + $builder = $this->createFormBuilder() + ->add('group_file', FileType::class, [ + 'label' => 'group.file', + ]); + + return $builder->getForm(); + } +} diff --git a/lib/Rozier/src/Controllers/HistoryController.php b/lib/Rozier/src/Controllers/HistoryController.php new file mode 100644 index 00000000..56e7a727 --- /dev/null +++ b/lib/Rozier/src/Controllers/HistoryController.php @@ -0,0 +1,112 @@ + "emergency", + Log::CRITICAL => "critical", + Log::ALERT => "alert", + Log::ERROR => "error", + Log::WARNING => "warning", + Log::NOTICE => "notice", + Log::INFO => "info", + Log::DEBUG => "debug", + ]; + + /** + * List all logs action. + * + * @param Request $request + * + * @return Response + * @throws RuntimeError + */ + public function indexAction(Request $request): Response + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_LOGS'); + + /* + * Manage get request to filter list + */ + $listManager = $this->createEntityListManager( + Log::class, + [], + ['datetime' => 'DESC'] + ); + $listManager->setDisplayingNotPublishedNodes(true); + $listManager->setDisplayingAllNodesStatuses(true); + $listManager->handle(); + + $this->assignation['filters'] = $listManager->getAssignation(); + $this->assignation['logs'] = $listManager->getEntities(); + $this->assignation['levels'] = static::$levelToHuman; + + return $this->render('@RoadizRozier/history/list.html.twig', $this->assignation); + } + + /** + * List user logs action. + * + * @param Request $request + * @param int $userId + * + * @return Response + * @throws \Doctrine\ORM\ORMException + * @throws \Doctrine\ORM\OptimisticLockException + * @throws \Doctrine\ORM\TransactionRequiredException + */ + public function userAction(Request $request, int $userId): Response + { + $this->denyAccessUnlessGranted(['ROLE_BACKEND_USER', 'ROLE_ACCESS_LOGS']); + + if ( + !($this->isGranted(['ROLE_ACCESS_USERS', 'ROLE_ACCESS_LOGS']) + || ($this->getUser() instanceof User && $this->getUser()->getId() == $userId)) + ) { + throw $this->createAccessDeniedException("You don't have access to this page: ROLE_ACCESS_USERS"); + } + + /** @var User|null $user */ + $user = $this->em()->find(User::class, $userId); + + if (null === $user) { + throw new ResourceNotFoundException(); + } + + /* + * Manage get request to filter list + */ + $listManager = $this->createEntityListManager( + Log::class, + ['user' => $user], + ['datetime' => 'DESC'] + ); + $listManager->setDisplayingNotPublishedNodes(true); + $listManager->setDisplayingAllNodesStatuses(true); + $listManager->setItemPerPage(30); + $listManager->handle(); + + $this->assignation['filters'] = $listManager->getAssignation(); + $this->assignation['logs'] = $listManager->getEntities(); + $this->assignation['levels'] = static::$levelToHuman; + $this->assignation['user'] = $user; + + return $this->render('@RoadizRozier/history/list.html.twig', $this->assignation); + } +} diff --git a/lib/Rozier/src/Controllers/LoginController.php b/lib/Rozier/src/Controllers/LoginController.php new file mode 100644 index 00000000..0e66ac8a --- /dev/null +++ b/lib/Rozier/src/Controllers/LoginController.php @@ -0,0 +1,67 @@ +documentUrlGenerator = $documentUrlGenerator; + $this->randomImageFinder = $randomImageFinder; + } + + /** + * @param Request $request + * + * @return Response + */ + public function imageAction(Request $request): Response + { + $response = new JsonResponse(); + if (null !== $document = $this->getSettingsBag()->getDocument('login_image')) { + if ($document instanceof Document && $document->isProcessable()) { + $this->documentUrlGenerator->setDocument($document); + $this->documentUrlGenerator->setOptions([ + 'width' => 1920, + 'height' => 1920, + 'quality' => 80, + 'sharpen' => 5, + ]); + $response->setData([ + 'url' => $this->documentUrlGenerator->getUrl() + ]); + return $this->makeResponseCachable($request, $response, 60, true); + } + } + + $feed = $this->randomImageFinder->getRandomBySearch('road'); + $url = null; + + if (null !== $feed) { + $url = $feed['url'] ?? $feed['urls']['regular'] ?? $feed['urls']['full'] ?? $feed['urls']['raw'] ?? null; + } + $response->setData([ + 'url' => '/themes/Rozier/static/assets/img/default_login.jpg' + ]); + return $this->makeResponseCachable($request, $response, 60, true); + } +} diff --git a/lib/Rozier/src/Controllers/LoginResetController.php b/lib/Rozier/src/Controllers/LoginResetController.php new file mode 100644 index 00000000..d3cd05e1 --- /dev/null +++ b/lib/Rozier/src/Controllers/LoginResetController.php @@ -0,0 +1,57 @@ +getUserByToken($this->em(), $token); + + if (null !== $user) { + $form = $this->createForm(LoginResetForm::class, null, [ + 'token' => $token, + 'confirmationTtl' => User::CONFIRMATION_TTL, + ]); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + if ($this->updateUserPassword($form, $user, $this->em())) { + return $this->redirectToRoute( + 'loginResetConfirmPage' + ); + } + } + $this->assignation['form'] = $form->createView(); + } else { + $this->assignation['error'] = $this->getTranslator()->trans('confirmation.token.is.invalid'); + } + + return $this->render('@RoadizRozier/login/reset.html.twig', $this->assignation); + } + + public function confirmAction(): Response + { + return $this->render('@RoadizRozier/login/resetConfirm.html.twig', $this->assignation); + } +} diff --git a/lib/Rozier/src/Controllers/NodeTypeFieldsController.php b/lib/Rozier/src/Controllers/NodeTypeFieldsController.php new file mode 100644 index 00000000..5596ab58 --- /dev/null +++ b/lib/Rozier/src/Controllers/NodeTypeFieldsController.php @@ -0,0 +1,215 @@ +messageBus = $messageBus; + } + + /** + * @param Request $request + * @param int $nodeTypeId + * + * @return Response + * @throws RuntimeError + */ + public function listAction(Request $request, int $nodeTypeId): Response + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_NODETYPES'); + + /** @var NodeType|null $nodeType */ + $nodeType = $this->em()->find(NodeType::class, $nodeTypeId); + + if ($nodeType === null) { + throw new ResourceNotFoundException(); + } + + $fields = $nodeType->getFields(); + + $this->assignation['nodeType'] = $nodeType; + $this->assignation['fields'] = $fields; + + return $this->render('@RoadizRozier/node-type-fields/list.html.twig', $this->assignation); + } + + /** + * @param Request $request + * @param int $nodeTypeFieldId + * + * @return Response + * @throws RuntimeError + */ + public function editAction(Request $request, int $nodeTypeFieldId): Response + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_NODETYPES'); + + /** @var NodeTypeField|null $field */ + $field = $this->em()->find(NodeTypeField::class, $nodeTypeFieldId); + + if ($field === null) { + throw new ResourceNotFoundException(); + } + + $this->assignation['nodeType'] = $field->getNodeType(); + $this->assignation['field'] = $field; + + $form = $this->createForm(NodeTypeFieldType::class, $field); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $this->em()->flush(); + + /** @var NodeType $nodeType */ + $nodeType = $field->getNodeType(); + $this->messageBus->dispatch(new Envelope(new UpdateNodeTypeSchemaMessage($nodeType->getId()))); + + $msg = $this->getTranslator()->trans('nodeTypeField.%name%.updated', ['%name%' => $field->getName()]); + $this->publishConfirmMessage($request, $msg); + + return $this->redirectToRoute( + 'nodeTypeFieldsEditPage', + [ + 'nodeTypeFieldId' => $nodeTypeFieldId, + ] + ); + } + + $this->assignation['form'] = $form->createView(); + + return $this->render('@RoadizRozier/node-type-fields/edit.html.twig', $this->assignation); + } + + /** + * @param Request $request + * @param int $nodeTypeId + * + * @return Response + * @throws RuntimeError + */ + public function addAction(Request $request, int $nodeTypeId): Response + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_NODETYPES'); + + $field = new NodeTypeField(); + /** @var NodeType|null $nodeType */ + $nodeType = $this->em()->find(NodeType::class, $nodeTypeId); + + if ($nodeType === null) { + throw new ResourceNotFoundException(); + } + + $latestPosition = $this->em() + ->getRepository(NodeTypeField::class) + ->findLatestPositionInNodeType($nodeType); + $field->setNodeType($nodeType); + $field->setPosition($latestPosition + 1); + $field->setType(NodeTypeField::STRING_T); + + $this->assignation['nodeType'] = $nodeType; + $this->assignation['field'] = $field; + + $form = $this->createForm(NodeTypeFieldType::class, $field); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + try { + $this->em()->persist($field); + $this->em()->flush(); + $this->em()->refresh($nodeType); + + $this->messageBus->dispatch(new Envelope(new UpdateNodeTypeSchemaMessage($nodeType->getId()))); + + $msg = $this->getTranslator()->trans( + 'nodeTypeField.%name%.created', + ['%name%' => $field->getName()] + ); + $this->publishConfirmMessage($request, $msg); + + return $this->redirectToRoute( + 'nodeTypeFieldsListPage', + [ + 'nodeTypeId' => $nodeTypeId, + ] + ); + } catch (Exception $e) { + $form->addError(new FormError($e->getMessage())); + } + } + + $this->assignation['form'] = $form->createView(); + + return $this->render('@RoadizRozier/node-type-fields/add.html.twig', $this->assignation); + } + + /** + * @param Request $request + * @param int $nodeTypeFieldId + * + * @return Response + * @throws RuntimeError + */ + public function deleteAction(Request $request, int $nodeTypeFieldId): Response + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_NODEFIELDS_DELETE'); + + /** @var NodeTypeField|null $field */ + $field = $this->em()->find(NodeTypeField::class, $nodeTypeFieldId); + + if ($field === null) { + throw new ResourceNotFoundException(); + } + + $form = $this->createForm(FormType::class); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + /** @var NodeType $nodeType */ + $nodeType = $field->getNodeType(); + $nodeTypeId = $nodeType->getId(); + $this->em()->remove($field); + $this->em()->flush(); + + $this->messageBus->dispatch(new Envelope(new UpdateNodeTypeSchemaMessage($nodeTypeId))); + + $msg = $this->getTranslator()->trans( + 'nodeTypeField.%name%.deleted', + ['%name%' => $field->getName()] + ); + $this->publishConfirmMessage($request, $msg); + + return $this->redirectToRoute( + 'nodeTypeFieldsListPage', + [ + 'nodeTypeId' => $nodeTypeId, + ] + ); + } + + $this->assignation['field'] = $field; + $this->assignation['form'] = $form->createView(); + + return $this->render('@RoadizRozier/node-type-fields/delete.html.twig', $this->assignation); + } +} diff --git a/lib/Rozier/src/Controllers/NodeTypes/NodeTypesController.php b/lib/Rozier/src/Controllers/NodeTypes/NodeTypesController.php new file mode 100644 index 00000000..b06957f2 --- /dev/null +++ b/lib/Rozier/src/Controllers/NodeTypes/NodeTypesController.php @@ -0,0 +1,186 @@ +messageBus = $messageBus; + } + + /** + * List every node-types. + * + * @param Request $request + * + * @return Response + */ + public function indexAction(Request $request) + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_NODETYPES'); + /* + * Manage get request to filter list + */ + $listManager = $this->createEntityListManager( + NodeType::class, + [], + ['name' => 'ASC'] + ); + $listManager->setDisplayingNotPublishedNodes(true); + + /* + * Stored in session + */ + $sessionListFilter = new SessionListFilters('node_types_item_per_page'); + $sessionListFilter->handleItemPerPage($request, $listManager); + + $listManager->handle(); + + $this->assignation['filters'] = $listManager->getAssignation(); + $this->assignation['node_types'] = $listManager->getEntities(); + + return $this->render('@RoadizRozier/node-types/list.html.twig', $this->assignation); + } + + /** + * Return an edition form for requested node-type. + * + * @param Request $request + * @param int $nodeTypeId + * + * @return Response + */ + public function editAction(Request $request, int $nodeTypeId) + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_NODETYPES'); + + /** @var NodeType|null $nodeType */ + $nodeType = $this->em()->find(NodeType::class, $nodeTypeId); + + if (!($nodeType instanceof NodeType)) { + throw $this->createNotFoundException(); + } + + $form = $this->createForm(NodeTypeType::class, $nodeType); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + try { + $this->em()->flush(); + + $this->messageBus->dispatch(new Envelope(new UpdateNodeTypeSchemaMessage($nodeType->getId()))); + + $msg = $this->getTranslator()->trans('nodeType.%name%.updated', ['%name%' => $nodeType->getName()]); + $this->publishConfirmMessage($request, $msg); + + return $this->redirectToRoute('nodeTypesEditPage', [ + 'nodeTypeId' => $nodeTypeId + ]); + } catch (EntityAlreadyExistsException $e) { + $form->addError(new FormError($e->getMessage())); + } + } + + $this->assignation['form'] = $form->createView(); + $this->assignation['nodeType'] = $nodeType; + + return $this->render('@RoadizRozier/node-types/edit.html.twig', $this->assignation); + } + + /** + * Return an creation form for requested node-type. + * + * @param Request $request + * + * @return Response + */ + public function addAction(Request $request) + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_NODETYPES'); + + $nodeType = new NodeType(); + + $form = $this->createForm(NodeTypeType::class, $nodeType); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + try { + $this->em()->persist($nodeType); + $this->em()->flush(); + + $this->messageBus->dispatch(new Envelope(new UpdateNodeTypeSchemaMessage($nodeType->getId()))); + + $msg = $this->getTranslator()->trans('nodeType.%name%.created', ['%name%' => $nodeType->getName()]); + $this->publishConfirmMessage($request, $msg); + + return $this->redirectToRoute('nodeTypesEditPage', [ + 'nodeTypeId' => $nodeType->getId() + ]); + } catch (EntityAlreadyExistsException $e) { + $form->addError(new FormError($e->getMessage())); + } + } + + $this->assignation['form'] = $form->createView(); + $this->assignation['nodeType'] = $nodeType; + + return $this->render('@RoadizRozier/node-types/add.html.twig', $this->assignation); + } + + /** + * @param Request $request + * @param int $nodeTypeId + * + * @return Response + */ + public function deleteAction(Request $request, int $nodeTypeId) + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_NODETYPES_DELETE'); + + /** @var NodeType $nodeType */ + $nodeType = $this->em()->find(NodeType::class, $nodeTypeId); + + if (!($nodeType instanceof NodeType)) { + throw $this->createNotFoundException(); + } + + $form = $this->createForm(FormType::class); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $this->messageBus->dispatch(new Envelope(new DeleteNodeTypeMessage($nodeType->getId()))); + + $msg = $this->getTranslator()->trans('nodeType.%name%.deleted', ['%name%' => $nodeType->getName()]); + $this->publishConfirmMessage($request, $msg); + + return $this->redirectToRoute('nodeTypesHomePage'); + } + + $this->assignation['form'] = $form->createView(); + $this->assignation['nodeType'] = $nodeType; + + return $this->render('@RoadizRozier/node-types/delete.html.twig', $this->assignation); + } +} diff --git a/lib/Rozier/src/Controllers/NodeTypes/NodeTypesUtilsController.php b/lib/Rozier/src/Controllers/NodeTypes/NodeTypesUtilsController.php new file mode 100644 index 00000000..f0e656ca --- /dev/null +++ b/lib/Rozier/src/Controllers/NodeTypes/NodeTypesUtilsController.php @@ -0,0 +1,250 @@ +serializer = $serializer; + $this->nodeTypesBag = $nodeTypesBag; + $this->nodeTypesImporter = $nodeTypesImporter; + $this->messageBus = $messageBus; + } + + /** + * Export a Json file containing NodeType data and fields. + * + * @param Request $request + * @param int $nodeTypeId + * + * @return Response + */ + public function exportJsonFileAction(Request $request, int $nodeTypeId): Response + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_NODETYPES'); + + /** @var NodeType|null $nodeType */ + $nodeType = $this->em()->find(NodeType::class, $nodeTypeId); + + if (null === $nodeType) { + throw $this->createNotFoundException(); + } + + return new JsonResponse( + $this->serializer->serialize( + $nodeType, + 'json', + SerializationContext::create()->setGroups(['node_type', 'position']) + ), + JsonResponse::HTTP_OK, + [ + 'Content-Disposition' => sprintf('attachment; filename="%s"', $nodeType->getName() . '.json'), + ], + true + ); + } + + /** + * @param Request $request + * + * @return BinaryFileResponse + */ + public function exportDocumentationAction(Request $request): BinaryFileResponse + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_NODETYPES'); + + $documentationGenerator = new DocumentationGenerator($this->nodeTypesBag, $this->getTranslator()); + + $tmpfname = tempnam(sys_get_temp_dir(), date('Y-m-d-H-i-s') . '.zip'); + unlink($tmpfname); // Deprecated: ZipArchive::open(): Using empty file as ZipArchive is deprecated + $zipArchive = new ZipArchive(); + $zipArchive->open($tmpfname, ZipArchive::CREATE); + + $zipArchive->addFromString( + '_sidebar.md', + $documentationGenerator->getNavBar() + ); + + foreach ($documentationGenerator->getReachableTypeGenerators() as $reachableTypeGenerator) { + $zipArchive->addFromString( + $reachableTypeGenerator->getPath(), + $reachableTypeGenerator->getContents() + ); + } + + foreach ($documentationGenerator->getNonReachableTypeGenerators() as $nonReachableTypeGenerator) { + $zipArchive->addFromString( + $nonReachableTypeGenerator->getPath(), + $nonReachableTypeGenerator->getContents() + ); + } + + $zipArchive->close(); + $response = new BinaryFileResponse($tmpfname); + $response->setContentDisposition( + ResponseHeaderBag::DISPOSITION_ATTACHMENT, + 'documentation-' . date('Y-m-d-H-i-s') . '.zip' + ); + $response->deleteFileAfterSend(true); + + return $response; + } + + /** + * @param Request $request + * + * @return Response + */ + public function exportTypeScriptDeclarationAction(Request $request): Response + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_NODETYPES'); + + $documentationGenerator = new DeclarationGenerator( + new DeclarationGeneratorFactory($this->nodeTypesBag) + ); + + $fileName = 'roadiz-app-' . date('Ymd-His') . '.d.ts'; + $response = new Response($documentationGenerator->getContents(), Response::HTTP_OK, [ + 'Content-type' => 'application/x-typescript', + 'Content-Disposition' => 'attachment; filename="' . $fileName . '"', + ]); + $response->prepare($request); + return $response; + } + + /** + * @param Request $request + * @return BinaryFileResponse + */ + public function exportAllAction(Request $request): BinaryFileResponse + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_NODETYPES'); + + $nodeTypes = $this->em() + ->getRepository(NodeType::class) + ->findAll(); + + $zipArchive = new ZipArchive(); + $tmpfname = tempnam(sys_get_temp_dir(), date('Y-m-d-H-i-s') . '.zip'); + unlink($tmpfname); // Deprecated: ZipArchive::open(): Using empty file as ZipArchive is deprecated + $zipArchive->open($tmpfname, ZipArchive::CREATE); + + /** @var NodeType $nodeType */ + foreach ($nodeTypes as $nodeType) { + $zipArchive->addFromString( + $nodeType->getName() . '.json', + $this->serializer->serialize( + $nodeType, + 'json', + SerializationContext::create()->setGroups(['node_type', 'position']) + ) + ); + } + + $zipArchive->close(); + $response = new BinaryFileResponse($tmpfname); + $response->setContentDisposition( + ResponseHeaderBag::DISPOSITION_ATTACHMENT, + 'nodetypes-' . date('Y-m-d-H-i-s') . '.zip' + ); + $response->deleteFileAfterSend(true); + + return $response; + } + + /** + * Import a Json file (.json) containing NodeType datas and fields. + * + * @param Request $request + * + * @return Response + * @throws RuntimeError + */ + public function importJsonFileAction(Request $request): Response + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_NODETYPES'); + + $form = $this->buildImportJsonFileForm(); + + $form->handleRequest($request); + + if ( + $form->isSubmitted() && + $form->isValid() && + !empty($form['node_type_file']) + ) { + $file = $form['node_type_file']->getData(); + + if ($file->isValid()) { + $serializedData = file_get_contents($file->getPathname()); + + if (null !== json_decode($serializedData)) { + $this->nodeTypesImporter->import($serializedData); + $this->em()->flush(); + + $this->messageBus->dispatch(new Envelope(new UpdateDoctrineSchemaMessage())); + + /* + * Redirect to update schema page + */ + return $this->redirectToRoute('nodeTypesHomePage'); + } + $form->addError(new FormError($this->getTranslator()->trans('file.format.not_valid'))); + } else { + $form->addError(new FormError($this->getTranslator()->trans('file.not_uploaded'))); + } + } + + $this->assignation['form'] = $form->createView(); + + return $this->render('@RoadizRozier/node-types/import.html.twig', $this->assignation); + } + + /** + * @return FormInterface + */ + private function buildImportJsonFileForm() + { + $builder = $this->createFormBuilder() + ->add('node_type_file', FileType::class, [ + 'label' => 'nodeType.file', + ]); + + return $builder->getForm(); + } +} diff --git a/lib/Rozier/src/Controllers/Nodes/ExportController.php b/lib/Rozier/src/Controllers/Nodes/ExportController.php new file mode 100644 index 00000000..61be7980 --- /dev/null +++ b/lib/Rozier/src/Controllers/Nodes/ExportController.php @@ -0,0 +1,96 @@ +xlsxSerializer = $xlsxSerializer; + } + + /** + * Export all Node in a XLSX file (Excel). + * + * @param Request $request + * @param int $translationId + * @param int|null $parentNodeId + * + * @return Response + * @throws \PhpOffice\PhpSpreadsheet\Exception + * @throws \PhpOffice\PhpSpreadsheet\Writer\Exception + */ + public function exportAllXlsxAction(Request $request, int $translationId, ?int $parentNodeId = null): Response + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_NODES'); + + /* + * Get translation + */ + $translation = $this->em() + ->find(Translation::class, $translationId); + + if (null === $translation) { + $translation = $this->em() + ->getRepository(Translation::class) + ->findDefault(); + } + $criteria = ["translation" => $translation]; + $order = ['node.nodeType' => 'ASC']; + $filename = 'nodes-' . date("YmdHis") . '.' . $translation->getLocale() . '.xlsx'; + + if (null !== $parentNodeId) { + /** @var Node|null $parentNode */ + $parentNode = $this->em()->find(Node::class, $parentNodeId); + if (null === $parentNode) { + throw $this->createNotFoundException(); + } + $criteria['node.parent'] = $parentNode; + $filename = $parentNode->getNodeName() . '-' . date("YmdHis") . '.' . $translation->getLocale() . '.xlsx'; + } + + $sources = $this->em() + ->getRepository(NodesSources::class) + ->setDisplayingAllNodesStatuses(true) + ->setDisplayingNotPublishedNodes(true) + ->findBy($criteria, $order); + + $this->xlsxSerializer->setOnlyTexts(true); + $this->xlsxSerializer->addUrls($request, $this->getSettingsBag()->get('force_locale')); + $xlsx = $this->xlsxSerializer->serialize($sources); + + $response = new Response( + $xlsx, + Response::HTTP_OK, + [] + ); + + $response->headers->set( + 'Content-Disposition', + $response->headers->makeDisposition( + ResponseHeaderBag::DISPOSITION_ATTACHMENT, + $filename + ) + ); + + $response->prepare($request); + + return $response; + } +} diff --git a/lib/Rozier/src/Controllers/Nodes/HistoryController.php b/lib/Rozier/src/Controllers/Nodes/HistoryController.php new file mode 100644 index 00000000..54ef618d --- /dev/null +++ b/lib/Rozier/src/Controllers/Nodes/HistoryController.php @@ -0,0 +1,61 @@ +denyAccessUnlessGranted(['ROLE_ACCESS_NODES', 'ROLE_ACCESS_LOGS']); + /** @var Node|null $node */ + $node = $this->em()->find(Node::class, $nodeId); + + if (null === $node) { + throw new ResourceNotFoundException(); + } + + $listManager = $this->createEntityListManager( + Log::class, + [ + 'nodeSource' => $node->getNodeSources()->toArray(), + ], + ['datetime' => 'DESC'] + ); + $listManager->setDisplayingNotPublishedNodes(true); + $listManager->setDisplayingAllNodesStatuses(true); + /* + * Stored in session + */ + $sessionListFilter = new SessionListFilters('user_history_item_per_page'); + $sessionListFilter->handleItemPerPage($request, $listManager); + $listManager->handle(); + + $this->assignation['node'] = $node; + $this->assignation['translation'] = $this->em()->getRepository(Translation::class)->findDefault(); + $this->assignation['entries'] = $listManager->getEntities(); + $this->assignation['filters'] = $listManager->getAssignation(); + + return $this->render('@RoadizRozier/nodes/history.html.twig', $this->assignation); + } +} diff --git a/lib/Rozier/src/Controllers/Nodes/NodesAttributesController.php b/lib/Rozier/src/Controllers/Nodes/NodesAttributesController.php new file mode 100644 index 00000000..a5c5af2d --- /dev/null +++ b/lib/Rozier/src/Controllers/Nodes/NodesAttributesController.php @@ -0,0 +1,329 @@ +formFactory = $formFactory; + } + + /** + * @param Request $request + * @param int $nodeId + * @param int $translationId + * + * @return Response + * @throws RuntimeError + */ + public function editAction(Request $request, int $nodeId, int $translationId): Response + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_NODE_ATTRIBUTES'); + + /** @var Translation|null $translation */ + $translation = $this->em()->find(Translation::class, $translationId); + /** @var Node|null $node */ + $node = $this->em()->find(Node::class, $nodeId); + + if (null === $translation || null === $node) { + throw $this->createNotFoundException('Node-source does not exist'); + } + + /** @var NodesSources|null $nodeSource */ + $nodeSource = $this->em() + ->getRepository(NodesSources::class) + ->setDisplayingAllNodesStatuses(true) + ->setDisplayingNotPublishedNodes(true) + ->findOneBy(['translation' => $translation, 'node' => $node]); + + if (null === $nodeSource) { + throw $this->createNotFoundException('Node-source does not exist'); + } + + if (null !== $response = $this->handleAddAttributeForm($request, $node, $translation)) { + return $response; + } + + $this->assignation['attribute_value_translation_forms'] = []; + $attributeValues = $node->getAttributeValues(); + /** @var AttributeValue $attributeValue */ + foreach ($attributeValues as $attributeValue) { + $name = $node->getNodeName() . '_attribute_' . $attributeValue->getId(); + $attributeValueTranslation = $attributeValue->getAttributeValueTranslation($translation); + if (null === $attributeValueTranslation) { + $attributeValueTranslation = new AttributeValueTranslation(); + $attributeValueTranslation->setAttributeValue($attributeValue); + $attributeValueTranslation->setTranslation($translation); + $this->em()->persist($attributeValueTranslation); + } + $attributeValueTranslationForm = $this->formFactory->createNamedBuilder( + $name, + AttributeValueTranslationType::class, + $attributeValueTranslation + )->getForm(); + $attributeValueTranslationForm->handleRequest($request); + + if ($attributeValueTranslationForm->isSubmitted()) { + if ($attributeValueTranslationForm->isValid()) { + $this->em()->flush(); + + /* + * Dispatch event + */ + $this->dispatchEvent(new NodesSourcesUpdatedEvent($nodeSource)); + + $msg = $this->getTranslator()->trans( + 'attribute_value_translation.%name%.updated_from_node.%nodeName%', + [ + '%name%' => $attributeValue->getAttribute()->getLabelOrCode($translation), + '%nodeName%' => $nodeSource->getTitle(), + ] + ); + $this->publishConfirmMessage($request, $msg, $nodeSource); + + if ($request->isXmlHttpRequest() || $request->getRequestFormat('html') === 'json') { + return new JsonResponse([ + 'status' => 'success', + 'message' => $msg, + ], JsonResponse::HTTP_ACCEPTED); + } + return $this->redirectToRoute('nodesEditAttributesPage', [ + 'nodeId' => $node->getId(), + 'translationId' => $translation->getId(), + ]); + } else { + $errors = $this->getErrorsAsArray($attributeValueTranslationForm); + /* + * Handle errors when Ajax POST requests + */ + if ($request->isXmlHttpRequest() || $request->getRequestFormat('html') === 'json') { + return new JsonResponse([ + 'status' => 'fail', + 'errors' => $errors, + 'message' => $this->getTranslator()->trans('form_has_errors.check_you_fields'), + ], JsonResponse::HTTP_BAD_REQUEST); + } + foreach ($errors as $error) { + $this->publishErrorMessage($request, $error); + } + } + } + + $this->assignation['attribute_value_translation_forms'][] = $attributeValueTranslationForm->createView(); + } + + $this->assignation['source'] = $nodeSource; + $this->assignation['translation'] = $translation; + $availableTranslations = $this->em() + ->getRepository(Translation::class) + ->findAvailableTranslationsForNode($node); + $this->assignation['available_translations'] = $availableTranslations; + $this->assignation['node'] = $node; + + return $this->render('@RoadizRozier/nodes/attributes/edit.html.twig', $this->assignation); + } + + /** + * @param Request $request + * @param Node $node + * @param Translation $translation + * + * @return RedirectResponse|null + */ + protected function handleAddAttributeForm(Request $request, Node $node, Translation $translation): ?RedirectResponse + { + $attributeValue = new AttributeValue(); + $attributeValue->setAttributable($node); + $addAttributeForm = $this->createForm(AttributeValueType::class, $attributeValue, [ + 'entityManager' => $this->em(), + 'translation' => $this->em()->getRepository(Translation::class)->findDefault(), + ]); + $addAttributeForm->handleRequest($request); + + if ($addAttributeForm->isSubmitted() && $addAttributeForm->isValid()) { + $this->em()->persist($attributeValue); + $this->em()->flush(); + + return $this->redirectToRoute('nodesEditAttributesPage', [ + 'nodeId' => $node->getId(), + 'translationId' => $translation->getId(), + ]); + } + $this->assignation['addAttributeForm'] = $addAttributeForm->createView(); + + return null; + } + + /** + * @param Request $request + * @param int $nodeId + * @param int $translationId + * @param int $attributeValueId + * + * @return RedirectResponse|Response + */ + public function deleteAction(Request $request, $nodeId, $translationId, $attributeValueId): Response + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_ATTRIBUTES_DELETE'); + + /** @var AttributeValue|null $item */ + $item = $this->em()->find(AttributeValue::class, $attributeValueId); + if ($item === null) { + throw $this->createNotFoundException('AttributeValue does not exist.'); + } + /** @var Translation|null $translation */ + $translation = $this->em()->find(Translation::class, $translationId); + /** @var Node|null $node */ + $node = $this->em()->find(Node::class, $nodeId); + + if (null === $translation || null === $node) { + throw $this->createNotFoundException('Node-source does not exist'); + } + + /** @var NodesSources|null $nodeSource */ + $nodeSource = $this->em() + ->getRepository(NodesSources::class) + ->setDisplayingAllNodesStatuses(true) + ->setDisplayingNotPublishedNodes(true) + ->findOneBy(['translation' => $translation, 'node' => $node]); + + if (null === $nodeSource) { + throw $this->createNotFoundException('Node-source does not exist'); + } + + $form = $this->createForm(FormType::class); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + try { + $this->em()->remove($item); + $this->em()->flush(); + + $msg = $this->getTranslator()->trans( + 'attribute.%name%.deleted_from_node.%nodeName%', + [ + '%name%' => $item->getAttribute()->getLabelOrCode($translation), + '%nodeName%' => $nodeSource->getTitle(), + ] + ); + $this->publishConfirmMessage($request, $msg); + } catch (\RuntimeException $e) { + $this->publishErrorMessage($request, $e->getMessage()); + } + + return $this->redirectToRoute('nodesEditAttributesPage', [ + 'nodeId' => $node->getId(), + 'translationId' => $translation->getId(), + ]); + } + + $this->assignation['form'] = $form->createView(); + $this->assignation['item'] = $item; + $this->assignation['source'] = $nodeSource; + $this->assignation['translation'] = $translation; + $this->assignation['node'] = $node; + + return $this->render('@RoadizRozier/nodes/attributes/delete.html.twig', $this->assignation); + } + + /** + * @param Request $request + * @param int $nodeId + * @param int $translationId + * @param int $attributeValueId + * @return Response + */ + public function resetAction(Request $request, int $nodeId, int $translationId, int $attributeValueId): Response + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_ATTRIBUTES_DELETE'); + + /** @var AttributeValueTranslation|null $item */ + $item = $this->em() + ->getRepository(AttributeValueTranslation::class) + ->findOneBy([ + 'attributeValue' => $attributeValueId, + 'translation' => $translationId + ]); + if ($item === null) { + throw $this->createNotFoundException('AttributeValueTranslation does not exist.'); + } + /** @var Translation|null $translation */ + $translation = $this->em()->find(Translation::class, $translationId); + /** @var Node|null $node */ + $node = $this->em()->find(Node::class, $nodeId); + + if (null === $translation || null === $node) { + throw $this->createNotFoundException('Node-source does not exist'); + } + + /** @var NodesSources|null $nodeSource */ + $nodeSource = $this->em() + ->getRepository(NodesSources::class) + ->setDisplayingAllNodesStatuses(true) + ->setDisplayingNotPublishedNodes(true) + ->findOneBy(['translation' => $translation, 'node' => $node]); + + if (null === $nodeSource) { + throw $this->createNotFoundException('Node-source does not exist'); + } + + $form = $this->createForm(FormType::class); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + try { + $this->em()->remove($item); + $this->em()->flush(); + + $msg = $this->getTranslator()->trans( + 'attribute.%name%.reset_for_node.%nodeName%', + [ + '%name%' => $item->getAttribute()->getLabelOrCode($translation), + '%nodeName%' => $nodeSource->getTitle(), + ] + ); + $this->publishConfirmMessage($request, $msg); + } catch (\RuntimeException $e) { + $this->publishErrorMessage($request, $e->getMessage()); + } + + return $this->redirectToRoute('nodesEditAttributesPage', [ + 'nodeId' => $node->getId(), + 'translationId' => $translation->getId(), + ]); + } + + $this->assignation['form'] = $form->createView(); + $this->assignation['item'] = $item; + $this->assignation['source'] = $nodeSource; + $this->assignation['translation'] = $translation; + $this->assignation['node'] = $node; + + return $this->render('@RoadizRozier/nodes/attributes/reset.html.twig', $this->assignation); + } +} diff --git a/lib/Rozier/src/Controllers/Nodes/NodesController.php b/lib/Rozier/src/Controllers/Nodes/NodesController.php new file mode 100644 index 00000000..e304e24f --- /dev/null +++ b/lib/Rozier/src/Controllers/Nodes/NodesController.php @@ -0,0 +1,764 @@ + + */ + private string $nodeFormTypeClass; + /** + * @var class-string + */ + private string $addNodeFormTypeClass; + + /** + * @param NodeChrootResolver $nodeChrootResolver + * @param NodeMover $nodeMover + * @param Registry $workflowRegistry + * @param HandlerFactoryInterface $handlerFactory + * @param UniqueNodeGenerator $uniqueNodeGenerator + * @param NodeFactory $nodeFactory + * @param class-string $nodeFormTypeClass + * @param class-string $addNodeFormTypeClass + */ + public function __construct( + NodeChrootResolver $nodeChrootResolver, + NodeMover $nodeMover, + Registry $workflowRegistry, + HandlerFactoryInterface $handlerFactory, + UniqueNodeGenerator $uniqueNodeGenerator, + NodeFactory $nodeFactory, + string $nodeFormTypeClass, + string $addNodeFormTypeClass + ) { + $this->nodeChrootResolver = $nodeChrootResolver; + $this->nodeMover = $nodeMover; + $this->workflowRegistry = $workflowRegistry; + $this->handlerFactory = $handlerFactory; + $this->nodeFormTypeClass = $nodeFormTypeClass; + $this->addNodeFormTypeClass = $addNodeFormTypeClass; + $this->uniqueNodeGenerator = $uniqueNodeGenerator; + $this->nodeFactory = $nodeFactory; + } + + protected function getNodeFactory(): NodeFactory + { + return $this->nodeFactory; + } + + /** + * List every node. + * + * @param Request $request + * @param string|null $filter + * + * @return Response + * @throws RuntimeError + */ + public function indexAction(Request $request, ?string $filter = null): Response + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_NODES'); + $translation = $this->em()->getRepository(Translation::class)->findDefault(); + + /** @var User|null $user */ + $user = $this->getUser(); + + switch ($filter) { + case 'draft': + $this->assignation['mainFilter'] = $filter; + $arrayFilter = [ + 'status' => Node::DRAFT, + ]; + break; + case 'pending': + $this->assignation['mainFilter'] = $filter; + $arrayFilter = [ + 'status' => Node::PENDING, + ]; + break; + case 'archived': + $this->assignation['mainFilter'] = $filter; + $arrayFilter = [ + 'status' => Node::ARCHIVED, + ]; + break; + case 'deleted': + $this->assignation['mainFilter'] = $filter; + $arrayFilter = [ + 'status' => Node::DELETED, + ]; + break; + default: + $this->assignation['mainFilter'] = 'all'; + $arrayFilter = []; + break; + } + + if (null !== $user) { + $arrayFilter["chroot"] = $this->nodeChrootResolver->getChroot($user); + } + + /* + * Manage get request to filter list + */ + $listManager = $this->createEntityListManager( + Node::class, + $arrayFilter + ); + $listManager->setDisplayingNotPublishedNodes(true); + $listManager->setDisplayingAllNodesStatuses(true); + + /* + * Stored in session + */ + $sessionListFilter = new SessionListFilters('node_list_item_per_page'); + $sessionListFilter->handleItemPerPage($request, $listManager); + $listManager->handle(); + + $this->assignation['filters'] = $listManager->getAssignation(); + $this->assignation['translation'] = $translation; + $this->assignation['availableTranslations'] = $this->em() + ->getRepository(Translation::class) + ->findAll(); + $this->assignation['nodes'] = $listManager->getEntities(); + $this->assignation['nodeTypes'] = $this->em() + ->getRepository(NodeType::class) + ->findBy([ + 'visible' => true, + ]); + + return $this->render('@RoadizRozier/nodes/list.html.twig', $this->assignation); + } + + /** + * Return an edition form for requested node. + * + * @param Request $request + * @param int $nodeId + * @param int|null $translationId + * + * @return Response + * @throws RuntimeError + */ + public function editAction(Request $request, int $nodeId, ?int $translationId = null): Response + { + $this->validateNodeAccessForRole('ROLE_ACCESS_NODES_SETTING', $nodeId); + + /** @var Node|null $node */ + $node = $this->em()->find(Node::class, $nodeId); + if (null === $node) { + throw new ResourceNotFoundException(sprintf('Node #%s does not exist.', $nodeId)); + } + + $this->em()->refresh($node); + /* + * Handle StackTypes form + */ + $stackTypesForm = $this->buildStackTypesForm($node); + if (null !== $stackTypesForm) { + $stackTypesForm->handleRequest($request); + if ($stackTypesForm->isSubmitted() && $stackTypesForm->isValid()) { + try { + $type = $this->addStackType($stackTypesForm->getData(), $node); + $msg = $this->getTranslator()->trans( + 'stack_node.%name%.has_new_type.%type%', + [ + '%name%' => $node->getNodeName(), + '%type%' => $type->getDisplayName(), + ] + ); + $this->publishConfirmMessage($request, $msg); + return $this->redirectToRoute( + 'nodesEditPage', + ['nodeId' => $node->getId()] + ); + } catch (EntityAlreadyExistsException $e) { + $stackTypesForm->addError(new FormError($e->getMessage())); + } + } + $this->assignation['stackTypesForm'] = $stackTypesForm->createView(); + } + + /* + * Handle main form + */ + $form = $this->createForm($this->nodeFormTypeClass, $node, [ + 'nodeName' => $node->getNodeName(), + ]); + try { + if ($node->getNodeType()->isReachable() && !$node->isHome()) { + $oldPaths = $this->nodeMover->getNodeSourcesUrls($node); + } + } catch (SameNodeUrlException $e) { + $oldPaths = []; + } + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + try { + $this->em()->flush(); + /* + * Dispatch event + */ + if (isset($oldPaths) && count($oldPaths) > 0 && !$node->isHome()) { + $this->dispatchEvent(new NodePathChangedEvent($node, $oldPaths)); + } + $this->dispatchEvent(new NodeUpdatedEvent($node)); + $this->em()->flush(); + $msg = $this->getTranslator()->trans('node.%name%.updated', [ + '%name%' => $node->getNodeName(), + ]); + $this->publishConfirmMessage($request, $msg, $node->getNodeSources()->first()); + return $this->redirectToRoute( + 'nodesEditPage', + ['nodeId' => $node->getId()] + ); + } catch (EntityAlreadyExistsException $e) { + $form->addError(new FormError($e->getMessage())); + } + } + + $translation = $this->em()->getRepository(Translation::class)->findDefault(); + $source = $node->getNodeSourcesByTranslation($translation)->first() ?: null; + + if (null === $source) { + $availableTranslations = $this->em() + ->getRepository(Translation::class) + ->findAvailableTranslationsForNode($node); + $this->assignation['available_translations'] = $availableTranslations; + } + $this->assignation['node'] = $node; + $this->assignation['source'] = $source; + $this->assignation['translation'] = $translation; + $this->assignation['form'] = $form->createView(); + + return $this->render('@RoadizRozier/nodes/edit.html.twig', $this->assignation); + } + + /** + * @param Request $request + * @param int $nodeId + * @param int $typeId + * @return Response + */ + public function removeStackTypeAction(Request $request, int $nodeId, int $typeId): Response + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_NODES'); + + /** @var Node|null $node */ + $node = $this->em()->find(Node::class, $nodeId); + if (null === $node) { + throw new ResourceNotFoundException(sprintf('Node #%s does not exist.', $nodeId)); + } + /** @var NodeType|null $type */ + $type = $this->em()->find(NodeType::class, $typeId); + if (null === $type) { + throw new ResourceNotFoundException(sprintf('NodeType #%s does not exist.', $typeId)); + } + + $node->removeStackType($type); + $this->em()->flush(); + + $msg = $this->getTranslator()->trans( + 'stack_type.%type%.has_been_removed.%name%', + [ + '%name%' => $node->getNodeName(), + '%type%' => $type->getDisplayName(), + ] + ); + $this->publishConfirmMessage($request, $msg, $node->getNodeSources()->first()); + + return $this->redirectToRoute('nodesEditPage', ['nodeId' => $node->getId()]); + } + + /** + * Handle node creation pages. + * + * @param Request $request + * @param int $nodeTypeId + * @param int|null $translationId + * + * @return Response + * @throws \Doctrine\ORM\ORMException + * @throws \Doctrine\ORM\OptimisticLockException + */ + public function addAction(Request $request, int $nodeTypeId, ?int $translationId = null): Response + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_NODES'); + + /** @var NodeType|null $type */ + $type = $this->em()->find(NodeType::class, $nodeTypeId); + if ($type === null) { + throw new ResourceNotFoundException(sprintf('Node-type #%s does not exist.', $nodeTypeId)); + } + + /** @var Translation|null $translation */ + $translation = $this->em()->getRepository(Translation::class)->findDefault(); + + if ($translationId !== null) { + $translation = $this->em()->find(Translation::class, $translationId); + } + if ($translation === null) { + throw new ResourceNotFoundException(sprintf('Translation #%s does not exist.', $translationId)); + } + + $node = new Node($type); + + $chroot = $this->nodeChrootResolver->getChroot($this->getUser()); + if (null !== $chroot) { + // If user is jailed in a node, prevent moving nodes out. + $node->setParent($chroot); + } + + $form = $this->createForm($this->addNodeFormTypeClass, $node, [ + 'nodeName' => '', + ]); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + try { + $node = $this->createNode($form->get('title')->getData(), $translation, $node); + $this->em()->refresh($node); + /* + * Dispatch event + */ + $this->dispatchEvent(new NodeCreatedEvent($node)); + + $msg = $this->getTranslator()->trans( + 'node.%name%.created', + ['%name%' => $node->getNodeName()] + ); + $this->publishConfirmMessage($request, $msg, $node->getNodeSources()->first()); + + return $this->redirectToRoute( + 'nodesEditSourcePage', + [ + 'nodeId' => $node->getId(), + 'translationId' => $translation->getId() + ] + ); + } catch (EntityAlreadyExistsException $e) { + $form->addError(new FormError($e->getMessage())); + } catch (\InvalidArgumentException $e) { + $form->addError(new FormError($e->getMessage())); + } + } + + $this->assignation['translation'] = $translation; + $this->assignation['form'] = $form->createView(); + $this->assignation['type'] = $type; + $this->assignation['nodeTypesCount'] = true; + + return $this->render('@RoadizRozier/nodes/add.html.twig', $this->assignation); + } + + /** + * Handle node creation pages. + * + * @param Request $request + * @param int|null $nodeId + * @param int|null $translationId + * + * @return Response + * @throws \Doctrine\ORM\ORMException + * @throws \Doctrine\ORM\OptimisticLockException + * @throws \Twig\Error\RuntimeError + */ + public function addChildAction(Request $request, ?int $nodeId = null, ?int $translationId = null): Response + { + // include CHRoot to enable creating node in it + $this->validateNodeAccessForRole('ROLE_ACCESS_NODES', $nodeId, true); + + /** @var Translation|null $translation */ + $translation = $this->em()->getRepository(Translation::class)->findDefault(); + + $nodeTypesCount = $this->em() + ->getRepository(NodeType::class) + ->countBy([]); + + if (null !== $translationId) { + /** @var Translation|null $translation */ + $translation = $this->em()->find(Translation::class, $translationId); + } + + if (null === $translation) { + throw new ResourceNotFoundException(sprintf('Translation does not exist')); + } + + if (null !== $nodeId && $nodeId > 0) { + /** @var Node $parentNode */ + $parentNode = $this->em() + ->find(Node::class, $nodeId); + } else { + $parentNode = null; + } + + $node = new Node(); + if (null !== $parentNode) { + $node->setParent($parentNode); + } + + $form = $this->createForm($this->addNodeFormTypeClass, $node, [ + 'nodeName' => '', + ]); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + try { + $node = $this->createNode($form->get('title')->getData(), $translation, $node); + $this->em()->refresh($node); + + /* + * Dispatch event + */ + $this->dispatchEvent(new NodeCreatedEvent($node)); + + $msg = $this->getTranslator()->trans( + 'child_node.%name%.created', + ['%name%' => $node->getNodeName()] + ); + $this->publishConfirmMessage($request, $msg, $node->getNodeSources()->first()); + + return $this->redirectToRoute( + 'nodesEditSourcePage', + [ + 'nodeId' => $node->getId(), + 'translationId' => $translation->getId() + ] + ); + } catch (EntityAlreadyExistsException $e) { + $form->addError(new FormError($e->getMessage())); + } catch (\InvalidArgumentException $e) { + $form->addError(new FormError($e->getMessage())); + } + } + + $this->assignation['translation'] = $translation; + $this->assignation['form'] = $form->createView(); + $this->assignation['parentNode'] = $parentNode; + $this->assignation['nodeTypesCount'] = $nodeTypesCount; + + return $this->render('@RoadizRozier/nodes/add.html.twig', $this->assignation); + } + + /** + * Return an deletion form for requested node. + * + * @param Request $request + * @param int $nodeId + * + * @return Response + * @throws \Twig\Error\RuntimeError + */ + public function deleteAction(Request $request, int $nodeId): Response + { + $this->validateNodeAccessForRole('ROLE_ACCESS_NODES_DELETE', $nodeId); + + /** @var Node|null $node */ + $node = $this->em()->find(Node::class, $nodeId); + + if (null === $node) { + throw new ResourceNotFoundException(sprintf('Node #%s does not exist.', $nodeId)); + } + + $workflow = $this->workflowRegistry->get($node); + if (!$workflow->can($node, 'delete')) { + $this->publishErrorMessage($request, sprintf('Node #%s cannot be deleted.', $nodeId)); + return $this->redirectToRoute( + 'nodesEditSourcePage', + [ + 'nodeId' => $node->getId(), + 'translationId' => $this->em()->getRepository(Translation::class)->findDefault()->getId() + ] + ); + } + + $this->assignation['node'] = $node; + $form = $this->buildDeleteForm($node); + $form->handleRequest($request); + + if ( + $form->isSubmitted() && + $form->isValid() && + $form->getData()['nodeId'] == $node->getId() + ) { + /** @var Node|null $parent */ + $parent = $node->getParent(); + /* + * Dispatch event + */ + $this->dispatchEvent(new NodeDeletedEvent($node)); + + /** @var NodeHandler $nodeHandler */ + $nodeHandler = $this->handlerFactory->getHandler($node); + $nodeHandler->softRemoveWithChildren(); + $this->em()->flush(); + + $msg = $this->getTranslator()->trans( + 'node.%name%.deleted', + ['%name%' => $node->getNodeName()] + ); + $this->publishConfirmMessage($request, $msg, $node->getNodeSources()->first()); + + if ( + $request->query->has('referer') && + (new UnicodeString($request->query->get('referer')))->startsWith('/') + ) { + return $this->redirect($request->query->get('referer')); + } + if (null !== $parent) { + return $this->redirectToRoute( + 'nodesEditSourcePage', + [ + 'nodeId' => $parent->getId(), + 'translationId' => $this->em()->getRepository(Translation::class)->findDefault()->getId() + ] + ); + } + return $this->redirectToRoute('nodesHomePage'); + } + $this->assignation['form'] = $form->createView(); + return $this->render('@RoadizRozier/nodes/delete.html.twig', $this->assignation); + } + + /** + * Empty trash action. + * + * @param Request $request + * + * @return Response + * @throws \Twig\Error\RuntimeError + */ + public function emptyTrashAction(Request $request): Response + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_NODES_DELETE'); + + $form = $this->buildEmptyTrashForm(); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $criteria = ['status' => Node::DELETED]; + /** @var Node|null $chroot */ + $chroot = $this->nodeChrootResolver->getChroot($this->getUser()); + if ($chroot !== null) { + /** @var NodeHandler $nodeHandler */ + $nodeHandler = $this->handlerFactory->getHandler($chroot); + $ids = $nodeHandler->getAllOffspringId(); + $criteria["parent"] = $ids; + } + + $nodes = $this->em() + ->getRepository(Node::class) + ->setDisplayingAllNodesStatuses(true) + ->setDisplayingNotPublishedNodes(true) + ->findBy($criteria); + + /** @var Node $node */ + foreach ($nodes as $node) { + /** @var NodeHandler $nodeHandler */ + $nodeHandler = $this->handlerFactory->getHandler($node); + $nodeHandler->removeWithChildrenAndAssociations(); + } + /* + * Final flush + */ + $this->em()->flush(); + + $msg = $this->getTranslator()->trans('node.trash.emptied'); + $this->publishConfirmMessage($request, $msg); + + return $this->redirectToRoute('nodesHomeDeletedPage'); + } + + $this->assignation['form'] = $form->createView(); + + return $this->render('@RoadizRozier/nodes/emptyTrash.html.twig', $this->assignation); + } + + /** + * Return an deletion form for requested node. + * + * @param Request $request + * @param int $nodeId + * + * @return Response + * @throws \Twig\Error\RuntimeError + */ + public function undeleteAction(Request $request, int $nodeId): Response + { + $this->validateNodeAccessForRole('ROLE_ACCESS_NODES_DELETE', $nodeId); + + /** @var Node|null $node */ + $node = $this->em()->find(Node::class, $nodeId); + + if (null === $node) { + throw new ResourceNotFoundException(sprintf('Node #%s does not exist.', $nodeId)); + } + + $workflow = $this->workflowRegistry->get($node); + if (!$workflow->can($node, 'undelete')) { + $this->publishErrorMessage($request, sprintf('Node #%s cannot be undeleted.', $nodeId)); + return $this->redirectToRoute( + 'nodesEditSourcePage', + [ + 'nodeId' => $node->getId(), + 'translationId' => $this->em()->getRepository(Translation::class)->findDefault()->getId() + ] + ); + } + + $this->assignation['node'] = $node; + $form = $this->createForm(FormType::class); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $this->dispatchEvent(new NodeUndeletedEvent($node)); + + /** @var NodeHandler $nodeHandler */ + $nodeHandler = $this->handlerFactory->getHandler($node); + $nodeHandler->softUnremoveWithChildren(); + $this->em()->flush(); + + $msg = $this->getTranslator()->trans( + 'node.%name%.undeleted', + ['%name%' => $node->getNodeName()] + ); + $this->publishConfirmMessage($request, $msg, $node->getNodeSources()->first()); + /* + * Force redirect to avoid resending form when refreshing page + */ + return $this->redirectToRoute('nodesEditPage', [ + 'nodeId' => $node->getId(), + ]); + } + + $this->assignation['form'] = $form->createView(); + + return $this->render('@RoadizRozier/nodes/undelete.html.twig', $this->assignation); + } + + /** + * @param Request $request + * @return RedirectResponse + */ + public function generateAndAddNodeAction(Request $request): RedirectResponse + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_NODES'); + + try { + $source = $this->uniqueNodeGenerator->generateFromRequest($request); + /** @var Translation $translation */ + $translation = $source->getTranslation(); + /* + * Dispatch event + */ + $this->dispatchEvent(new NodeCreatedEvent($source->getNode())); + + return $this->redirectToRoute( + 'nodesEditSourcePage', + [ + 'nodeId' => $source->getNode()->getId(), + 'translationId' => $translation->getId() + ] + ); + } catch (\Exception $e) { + $msg = $this->getTranslator()->trans('node.noCreation.alreadyExists'); + throw new ResourceNotFoundException($msg); + } + } + /** + * @param Request $request + * @param int $nodeId + * @return Response + */ + public function publishAllAction(Request $request, int $nodeId): Response + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_NODES_STATUS'); + /** @var Node|null $node */ + $node = $this->em()->find(Node::class, $nodeId); + + if (null === $node) { + throw new ResourceNotFoundException(sprintf('Node #%s does not exist.', $nodeId)); + } + + $workflow = $this->workflowRegistry->get($node); + if (!$workflow->can($node, 'publish')) { + $this->publishErrorMessage($request, sprintf('Node #%s cannot be published.', $nodeId)); + return $this->redirectToRoute( + 'nodesEditSourcePage', + [ + 'nodeId' => $node->getId(), + 'translationId' => $this->em()->getRepository(Translation::class)->findDefault()->getId() + ] + ); + } + + $form = $this->createForm(FormType::class); + $form->handleRequest($request); + if ($form->isSubmitted() && $form->isValid()) { + /** @var NodeHandler $nodeHandler */ + $nodeHandler = $this->handlerFactory->getHandler($node); + $nodeHandler->publishWithChildren(); + $this->em()->flush(); + + $msg = $this->getTranslator()->trans('node.offspring.published'); + $this->publishConfirmMessage($request, $msg); + + return $this->redirectToRoute('nodesEditSourcePage', [ + 'nodeId' => $nodeId, + 'translationId' => $node->getNodeSources()->first()->getTranslation()->getId(), + ]); + } + + $this->assignation['node'] = $node; + $this->assignation['form'] = $form->createView(); + + return $this->render('@RoadizRozier/nodes/publishAll.html.twig', $this->assignation); + } +} diff --git a/lib/Rozier/src/Controllers/Nodes/NodesSourcesController.php b/lib/Rozier/src/Controllers/Nodes/NodesSourcesController.php new file mode 100644 index 00000000..84012609 --- /dev/null +++ b/lib/Rozier/src/Controllers/Nodes/NodesSourcesController.php @@ -0,0 +1,309 @@ +jwtExtension = $jwtExtension; + $this->formErrorSerializer = $formErrorSerializer; + } + + /** + * Return an edition form for requested node. + * + * @param Request $request + * @param int $nodeId + * @param int $translationId + * + * @return Response + * @throws RuntimeError + */ + public function editSourceAction(Request $request, int $nodeId, int $translationId): Response + { + $this->validateNodeAccessForRole('ROLE_ACCESS_NODES', $nodeId); + + /** @var Translation|null $translation */ + $translation = $this->em()->find(Translation::class, $translationId); + + if (null === $translation) { + throw new ResourceNotFoundException('Translation does not exist'); + } + /* + * Here we need to directly select nodeSource + * if not doctrine will grab a cache tag because of NodeTreeWidget + * that is initialized before calling route method. + */ + /** @var Node|null $gNode */ + $gNode = $this->em()->find(Node::class, $nodeId); + if (null === $gNode) { + throw new ResourceNotFoundException('Node does not exist'); + } + + /** @var NodesSources|null $source */ + $source = $this->em() + ->getRepository(NodesSources::class) + ->setDisplayingAllNodesStatuses(true) + ->setDisplayingNotPublishedNodes(true) + ->findOneBy(['translation' => $translation, 'node' => $gNode]); + + if (null === $source) { + throw new ResourceNotFoundException('Node source does not exist'); + } + + $this->em()->refresh($source); + + $node = $source->getNode(); + + /** + * Versioning + */ + if ($this->isGranted('ROLE_ACCESS_VERSIONS')) { + if (null !== $response = $this->handleVersions($request, $source)) { + return $response; + } + } + + $form = $this->createForm( + NodeSourceType::class, + $source, + [ + 'class' => $node->getNodeType()->getSourceEntityFullQualifiedClassName(), + 'nodeType' => $node->getNodeType(), + 'withVirtual' => true, + 'withTitle' => true, + 'disabled' => $this->isReadOnly, + ] + ); + $form->handleRequest($request); + + if ($form->isSubmitted()) { + if ($form->isValid() && !$this->isReadOnly) { + $this->onPostUpdate($source, $request); + + if (!$request->isXmlHttpRequest()) { + return $this->getPostUpdateRedirection($source); + } + + $jwtToken = $this->jwtExtension->createPreviewJwt(); + + if ($this->getSettingsBag()->get('custom_preview_scheme')) { + $previewUrl = $this->generateUrl($source, [ + 'canonicalScheme' => $this->getSettingsBag()->get('custom_preview_scheme'), + 'token' => $jwtToken, + NodeRouter::NO_CACHE_PARAMETER => true + ], UrlGeneratorInterface::ABSOLUTE_URL); + } elseif ($this->getSettingsBag()->get('custom_public_scheme')) { + $previewUrl = $this->generateUrl($source, [ + 'canonicalScheme' => $this->getSettingsBag()->get('custom_public_scheme'), + '_preview' => 1, + 'token' => $jwtToken, + NodeRouter::NO_CACHE_PARAMETER => true + ], UrlGeneratorInterface::ABSOLUTE_URL); + } else { + $previewUrl = $this->generateUrl($source, [ + '_preview' => 1, + 'token' => $jwtToken, + NodeRouter::NO_CACHE_PARAMETER => true + ]); + } + + if ($this->getSettingsBag()->get('custom_public_scheme')) { + $publicUrl = $this->generateUrl($source, [ + 'canonicalScheme' => $this->getSettingsBag()->get('custom_public_scheme'), + NodeRouter::NO_CACHE_PARAMETER => true + ], UrlGeneratorInterface::ABSOLUTE_URL); + } else { + $publicUrl = $this->generateUrl($source, [ + NodeRouter::NO_CACHE_PARAMETER => true + ]); + } + + return new JsonResponse([ + 'status' => 'success', + 'public_url' => $source->getNode()->isPublished() ? $publicUrl : null, + 'preview_url' => $previewUrl, + 'errors' => [], + ], Response::HTTP_PARTIAL_CONTENT); + } + + if ($this->isReadOnly) { + $form->addError(new FormError('nodeSource.form.is_read_only')); + } + + /* + * Handle errors when Ajax POST requests + */ + if ($request->isXmlHttpRequest()) { + $errors = $this->formErrorSerializer->getErrorsAsArray($form); + return new JsonResponse([ + 'status' => 'fail', + 'errors' => $errors, + 'message' => $this->getTranslator()->trans('form_has_errors.check_you_fields'), + ], Response::HTTP_BAD_REQUEST); + } + } + + $availableTranslations = $this->em() + ->getRepository(Translation::class) + ->findAvailableTranslationsForNode($gNode); + + $this->assignation['translation'] = $translation; + $this->assignation['available_translations'] = $availableTranslations; + $this->assignation['node'] = $node; + $this->assignation['source'] = $source; + $this->assignation['form'] = $form->createView(); + $this->assignation['readOnly'] = $this->isReadOnly; + + return $this->render('@RoadizRozier/nodes/editSource.html.twig', $this->assignation); + } + + /** + * Return a remove form for requested nodeSource. + * + * @param Request $request + * @param int $nodeSourceId + * + * @return Response + * @throws RuntimeError + */ + public function removeAction(Request $request, int $nodeSourceId): Response + { + /** @var NodesSources|null $ns */ + $ns = $this->em()->find(NodesSources::class, $nodeSourceId); + if (null === $ns) { + throw new ResourceNotFoundException('Node source does not exist'); + } + /** @var Node $node */ + $node = $ns->getNode(); + $this->em()->refresh($ns->getNode()); + + $this->validateNodeAccessForRole('ROLE_ACCESS_NODES_DELETE', $node->getId()); + + /* + * Prevent deleting last node-source available in node. + */ + if ($node->getNodeSources()->count() <= 1) { + $msg = $this->getTranslator()->trans('node_source.%node_source%.%translation%.cant.deleted', [ + '%node_source%' => $node->getNodeName(), + '%translation%' => $ns->getTranslation()->getName(), + ]); + + throw new BadRequestHttpException($msg); + } + + $builder = $this->createFormBuilder() + ->add('nodeId', HiddenType::class, [ + 'data' => $nodeSourceId, + 'constraints' => [ + new NotNull(), + new NotBlank(), + ], + ]); + + $form = $builder->getForm(); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + /** @var Node $node */ + $node = $ns->getNode(); + /* + * Dispatch event + */ + $this->dispatchEvent(new NodesSourcesDeletedEvent($ns)); + + $this->em()->remove($ns); + $this->em()->flush(); + + $ns = $node->getNodeSources()->first(); + + $msg = $this->getTranslator()->trans('node_source.%node_source%.deleted.%translation%', [ + '%node_source%' => $node->getNodeName(), + '%translation%' => $ns->getTranslation()->getName(), + ]); + + $this->publishConfirmMessage($request, $msg); + + return $this->redirectToRoute( + 'nodesEditSourcePage', + ['nodeId' => $node->getId(), "translationId" => $ns->getTranslation()->getId()] + ); + } + + $this->assignation["nodeSource"] = $ns; + $this->assignation['form'] = $form->createView(); + + return $this->render('@RoadizRozier/nodes/deleteSource.html.twig', $this->assignation); + } + + protected function onPostUpdate(PersistableInterface $entity, Request $request): void + { + /* + * Dispatch pre-flush event + */ + if (!$entity instanceof NodesSources) { + return; + } + + $this->dispatchEvent(new NodesSourcesPreUpdatedEvent($entity)); + $this->em()->flush(); + $this->dispatchEvent(new NodesSourcesUpdatedEvent($entity)); + + $msg = $this->getTranslator()->trans('node_source.%node_source%.updated.%translation%', [ + '%node_source%' => $entity->getNode()->getNodeName(), + '%translation%' => $entity->getTranslation()->getName(), + ]); + + $this->publishConfirmMessage($request, $msg, $entity); + } + + protected function getPostUpdateRedirection(PersistableInterface $entity): ?Response + { + if (!$entity instanceof NodesSources) { + return null; + } + + /** @var Translation $translation */ + $translation = $entity->getTranslation(); + return $this->redirectToRoute( + 'nodesEditSourcePage', + [ + 'nodeId' => $entity->getNode()->getId(), + 'translationId' => $translation->getId() + ] + ); + } +} diff --git a/lib/Rozier/src/Controllers/Nodes/NodesTagsController.php b/lib/Rozier/src/Controllers/Nodes/NodesTagsController.php new file mode 100644 index 00000000..87f69398 --- /dev/null +++ b/lib/Rozier/src/Controllers/Nodes/NodesTagsController.php @@ -0,0 +1,104 @@ +nodeFactory = $nodeFactory; + } + + protected function getNodeFactory(): NodeFactory + { + return $this->nodeFactory; + } + + /** + * Return tags form for requested node. + * + * @param Request $request + * @param int $nodeId + * + * @return Response + * @throws \Twig\Error\RuntimeError + */ + public function editTagsAction(Request $request, int $nodeId): Response + { + $this->validateNodeAccessForRole('ROLE_ACCESS_NODES', $nodeId); + + /** @var NodesSources|null $source */ + $source = $this->em() + ->getRepository(NodesSources::class) + ->setDisplayingAllNodesStatuses(true) + ->setDisplayingNotPublishedNodes(true) + ->findOneBy([ + 'node.id' => $nodeId, + 'translation' => $this->em()->getRepository(Translation::class)->findDefault() + ]); + if (null === $source) { + /** @var NodesSources|null $source */ + $source = $this->em() + ->getRepository(NodesSources::class) + ->setDisplayingAllNodesStatuses(true) + ->setDisplayingNotPublishedNodes(true) + ->findOneBy([ + 'node.id' => $nodeId, + ]); + } + + if (null !== $source) { + $node = $source->getNode(); + $form = $this->createForm(NodeTagsType::class, $node); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + /* + * Dispatch event + */ + $this->dispatchEvent(new NodeTaggedEvent($node)); + $this->em()->flush(); + + $msg = $this->getTranslator()->trans('node.%node%.linked.tags', [ + '%node%' => $node->getNodeName(), + ]); + $this->publishConfirmMessage($request, $msg, $source); + + return $this->redirectToRoute( + 'nodesEditTagsPage', + ['nodeId' => $node->getId()] + ); + } + + $this->assignation['translation'] = $source->getTranslation(); + $this->assignation['node'] = $node; + $this->assignation['source'] = $source; + $this->assignation['form'] = $form->createView(); + + return $this->render('@RoadizRozier/nodes/editTags.html.twig', $this->assignation); + } + + throw new ResourceNotFoundException(); + } +} diff --git a/lib/Rozier/src/Controllers/Nodes/NodesTreesController.php b/lib/Rozier/src/Controllers/Nodes/NodesTreesController.php new file mode 100644 index 00000000..0e2f157f --- /dev/null +++ b/lib/Rozier/src/Controllers/Nodes/NodesTreesController.php @@ -0,0 +1,564 @@ +nodeChrootResolver = $nodeChrootResolver; + $this->treeWidgetFactory = $treeWidgetFactory; + $this->formFactory = $formFactory; + $this->handlerFactory = $handlerFactory; + $this->workflowRegistry = $workflowRegistry; + } + + /** + * @param Request $request + * @param int|null $nodeId + * @param int|null $translationId + * + * @return Response + */ + public function treeAction(Request $request, ?int $nodeId = null, ?int $translationId = null) + { + if (null !== $nodeId) { + $this->validateNodeAccessForRole('ROLE_ACCESS_NODES', $nodeId, true); + /** @var Node|null $node */ + $node = $this->em()->find(Node::class, $nodeId); + + if (null === $node) { + throw new ResourceNotFoundException(); + } + + $this->em()->refresh($node); + } elseif (null !== $this->getUser()) { + $node = $this->nodeChrootResolver->getChroot($this->getUser()); + } else { + $node = null; + } + + if (null !== $translationId) { + /** @var Translation $translation */ + $translation = $this->em() + ->getRepository(Translation::class) + ->findOneBy(['id' => $translationId]); + } else { + /** @var Translation $translation */ + $translation = $this->em()->getRepository(Translation::class)->findDefault(); + } + + $widget = $this->treeWidgetFactory->createNodeTree($node, $translation); + + if ( + $request->get('tagId') && + $request->get('tagId') > 0 + ) { + $filterTag = $this->em()->find(Tag::class, (int) $request->get('tagId')); + $this->assignation['filterTag'] = $filterTag; + $widget->setTag($filterTag); + } + + $widget->setStackTree(true); + $widget->getNodes(); //pre-fetch nodes for enable filters + + if (null !== $node) { + $this->assignation['node'] = $node; + + if ($node->isHidingChildren()) { + $this->assignation['availableTags'] = $this->em()->getRepository(Tag::class)->findAllLinkedToNodeChildren( + $node, + $translation + ); + } + $this->assignation['source'] = $node->getNodeSourcesByTranslation($translation)->first(); + $availableTranslations = $this->em() + ->getRepository(Translation::class) + ->findAvailableTranslationsForNode($node); + $this->assignation['available_translations'] = $availableTranslations; + } + $this->assignation['translation'] = $translation; + $this->assignation['specificNodeTree'] = $widget; + + /* + * Handle bulk tag form + */ + $tagNodesForm = $this->buildBulkTagForm(); + $tagNodesForm->handleRequest($request); + if ($tagNodesForm->isSubmitted() && $tagNodesForm->isValid()) { + $data = $tagNodesForm->getData(); + + $submitTag = $tagNodesForm->get('submitTag'); + $submitUntag = $tagNodesForm->get('submitUntag'); + if ($submitTag instanceof ClickableInterface && $submitTag->isClicked()) { + $msg = $this->tagNodes($data); + } elseif ($submitUntag instanceof ClickableInterface && $submitUntag->isClicked()) { + $msg = $this->untagNodes($data); + } else { + $msg = $this->getTranslator()->trans('wrong.request'); + } + + $this->publishConfirmMessage($request, $msg); + + return $this->redirectToRoute( + 'nodesTreePage', + ['nodeId' => $nodeId, 'translationId' => $translationId] + ); + } + $this->assignation['tagNodesForm'] = $tagNodesForm->createView(); + + /* + * Handle bulk status + */ + if ($this->isGranted('ROLE_ACCESS_NODES_STATUS')) { + $statusBulkNodes = $this->buildBulkStatusForm($request->getRequestUri()); + $this->assignation['statusNodesForm'] = $statusBulkNodes->createView(); + } + + if ($this->isGranted('ROLE_ACCESS_NODES_DELETE')) { + /* + * Handle bulk delete form + */ + $deleteNodesForm = $this->buildBulkDeleteForm($request->getRequestUri()); + $this->assignation['deleteNodesForm'] = $deleteNodesForm->createView(); + } + + return $this->render('@RoadizRozier/nodes/tree.html.twig', $this->assignation); + } + + /** + * @param Request $request + * @return Response + */ + public function bulkDeleteAction(Request $request) + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_NODES_DELETE'); + + if (!empty($request->get('deleteForm')['nodesIds'])) { + $nodesIds = trim($request->get('deleteForm')['nodesIds']); + $nodesIds = explode(',', $nodesIds); + array_filter($nodesIds); + + /** @var Node[] $nodes */ + $nodes = $this->em() + ->getRepository(Node::class) + ->setDisplayingNotPublishedNodes(true) + ->findBy([ + 'id' => $nodesIds, + ]); + + if (count($nodes) > 0) { + $form = $this->buildBulkDeleteForm( + $request->get('deleteForm')['referer'], + $nodesIds + ); + $form->handleRequest($request); + if ($request->get('confirm') && $form->isSubmitted() && $form->isValid()) { + $msg = $this->bulkDeleteNodes($form->getData()); + $this->publishConfirmMessage($request, $msg); + + if (!empty($form->getData()['referer'])) { + return $this->redirect($form->getData()['referer']); + } else { + return $this->redirectToRoute('nodesHomePage'); + } + } + + $this->assignation['nodes'] = $nodes; + $this->assignation['form'] = $form->createView(); + + if (!empty($request->get('deleteForm')['referer'])) { + $this->assignation['referer'] = $request->get('deleteForm')['referer']; + } + + return $this->render('@RoadizRozier/nodes/bulkDelete.html.twig', $this->assignation); + } + } + + throw new ResourceNotFoundException(); + } + + /** + * @param Request $request + * + * @return Response + */ + public function bulkStatusAction(Request $request) + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_NODES_STATUS'); + + if (!empty($request->get('statusForm')['nodesIds'])) { + $nodesIds = trim($request->get('statusForm')['nodesIds']); + $nodesIds = explode(',', $nodesIds); + array_filter($nodesIds); + + /** @var Node[] $nodes */ + $nodes = $this->em() + ->getRepository(Node::class) + ->setDisplayingNotPublishedNodes(true) + ->findBy([ + 'id' => $nodesIds, + ]); + if (count($nodes) > 0) { + $form = $this->buildBulkStatusForm( + $request->get('statusForm')['referer'], + $nodesIds, + (string) $request->get('statusForm')['status'] + ); + + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $msg = $this->bulkStatusNodes($form->getData()); + $this->publishConfirmMessage($request, $msg); + + if (!empty($form->getData()['referer'])) { + return $this->redirect($form->getData()['referer']); + } else { + return $this->redirectToRoute('nodesHomePage'); + } + } + + $this->assignation['nodes'] = $nodes; + $this->assignation['form'] = $form->createView(); + + if (!empty($request->get('statusForm')['referer'])) { + $this->assignation['referer'] = $request->get('statusForm')['referer']; + } + + return $this->render('@RoadizRozier/nodes/bulkStatus.html.twig', $this->assignation); + } + } + + throw new ResourceNotFoundException(); + } + + /** + * @param false|string $referer + * @param array $nodesIds + * + * @return FormInterface + */ + private function buildBulkDeleteForm( + $referer = false, + $nodesIds = [] + ) { + /** @var FormBuilder $builder */ + $builder = $this->formFactory + ->createNamedBuilder('deleteForm') + ->add('nodesIds', HiddenType::class, [ + 'data' => implode(',', $nodesIds), + 'attr' => ['class' => 'nodes-id-bulk-tags'], + 'constraints' => [ + new NotNull(), + new NotBlank(), + ], + ]); + + if (false !== $referer && (new UnicodeString($referer))->startsWith('/')) { + $builder->add('referer', HiddenType::class, [ + 'data' => $referer, + ]); + } + + return $builder->getForm(); + } + + /** + * @param array $data + * + * @return string + */ + private function bulkDeleteNodes(array $data) + { + if (!empty($data['nodesIds'])) { + $nodesIds = trim($data['nodesIds']); + $nodesIds = explode(',', $nodesIds); + array_filter($nodesIds); + + $nodes = $this->em() + ->getRepository(Node::class) + ->setDisplayingNotPublishedNodes(true) + ->findBy([ + 'id' => $nodesIds, + ]); + + /** @var Node $node */ + foreach ($nodes as $node) { + /** @var NodeHandler $handler */ + $handler = $this->handlerFactory->getHandler($node); + $handler->softRemoveWithChildren(); + } + + $this->em()->flush(); + + return $this->getTranslator()->trans('nodes.bulk.deleted'); + } + + return $this->getTranslator()->trans('wrong.request'); + } + + /** + * @param array $data + * + * @return string + */ + private function bulkStatusNodes(array $data) + { + if (!empty($data['nodesIds'])) { + $nodesIds = trim($data['nodesIds']); + $nodesIds = explode(',', $nodesIds); + array_filter($nodesIds); + + /** @var Node[] $nodes */ + $nodes = $this->em() + ->getRepository(Node::class) + ->setDisplayingNotPublishedNodes(true) + ->findBy([ + 'id' => $nodesIds, + ]) + ; + + foreach ($nodes as $node) { + $workflow = $this->workflowRegistry->get($node); + if ($workflow->can($node, $data['status'])) { + $workflow->apply($node, $data['status']); + } + } + $this->em()->flush(); + return $this->getTranslator()->trans('nodes.bulk.status.changed'); + } + + return $this->getTranslator()->trans('wrong.request'); + } + + /** + * @return FormInterface + */ + private function buildBulkTagForm() + { + /** @var FormBuilder $builder */ + $builder = $this->formFactory + ->createNamedBuilder('tagForm') + ->add('nodesIds', HiddenType::class, [ + 'attr' => ['class' => 'nodes-id-bulk-tags'], + 'constraints' => [ + new NotNull(), + new NotBlank(), + ], + ]) + ->add('tagsPaths', TextType::class, [ + 'label' => false, + 'attr' => [ + 'class' => 'rz-tag-autocomplete', + 'placeholder' => 'list.tags.to_link.or_unlink', + ], + 'constraints' => [ + new NotNull(), + new NotBlank(), + ], + ]) + ->add('submitTag', SubmitType::class, [ + 'label' => 'link.tags', + 'attr' => [ + 'class' => 'uk-button uk-button-primary', + 'title' => 'link.tags', + 'data-uk-tooltip' => "{animation:true}", + ], + ]) + ->add('submitUntag', SubmitType::class, [ + 'label' => 'unlink.tags', + 'attr' => [ + 'class' => 'uk-button', + 'title' => 'unlink.tags', + 'data-uk-tooltip' => "{animation:true}", + ], + ]) + ; + + return $builder->getForm(); + } + + /** + * @param array $data + * @return string + */ + private function tagNodes(array $data) + { + $msg = $this->getTranslator()->trans('nodes.bulk.not_tagged'); + + if ( + !empty($data['tagsPaths']) && + !empty($data['nodesIds']) + ) { + $nodesIds = explode(',', $data['nodesIds']); + $nodesIds = array_filter($nodesIds); + + /** @var Node[] $nodes */ + $nodes = $this->em() + ->getRepository(Node::class) + ->setDisplayingNotPublishedNodes(true) + ->findBy([ + 'id' => $nodesIds, + ]); + + $paths = explode(',', $data['tagsPaths']); + $paths = array_filter($paths); + + foreach ($paths as $path) { + $tag = $this->em() + ->getRepository(Tag::class) + ->findOrCreateByPath($path); + + foreach ($nodes as $node) { + $node->addTag($tag); + } + } + $msg = $this->getTranslator()->trans('nodes.bulk.tagged'); + } + + $this->em()->flush(); + + return $msg; + } + + /** + * @param array $data + * @return string + */ + private function untagNodes(array $data) + { + $msg = $this->getTranslator()->trans('nodes.bulk.not_untagged'); + + if ( + !empty($data['tagsPaths']) && + !empty($data['nodesIds']) + ) { + $nodesIds = explode(',', $data['nodesIds']); + $nodesIds = array_filter($nodesIds); + + /** @var Node[] $nodes */ + $nodes = $this->em() + ->getRepository(Node::class) + ->setDisplayingNotPublishedNodes(true) + ->findBy([ + 'id' => $nodesIds, + ]); + + $paths = explode(',', $data['tagsPaths']); + $paths = array_filter($paths); + + foreach ($paths as $path) { + $tag = $this->em() + ->getRepository(Tag::class) + ->findByPath($path); + + if (null !== $tag) { + foreach ($nodes as $node) { + $node->removeTag($tag); + } + } + } + $msg = $this->getTranslator()->trans('nodes.bulk.untagged'); + } + + $this->em()->flush(); + + return $msg; + } + + /** + * @param false|string $referer + * @param array $nodesIds + * @param string $status + * + * @return FormInterface + */ + private function buildBulkStatusForm( + $referer = false, + $nodesIds = [], + $status = 'reject' + ) { + /** @var FormBuilder $builder */ + $builder = $this->formFactory + ->createNamedBuilder('statusForm') + ->add('nodesIds', HiddenType::class, [ + 'attr' => ['class' => 'nodes-id-bulk-status'], + 'data' => implode(',', $nodesIds), + 'constraints' => [ + new NotBlank(), + new NotNull(), + ], + ]) + ->add('status', ChoiceType::class, [ + 'label' => false, + 'data' => $status, + 'choices' => [ + Node::getStatusLabel(Node::DRAFT) => 'reject', + Node::getStatusLabel(Node::PENDING) => 'review', + Node::getStatusLabel(Node::PUBLISHED) => 'publish', + Node::getStatusLabel(Node::ARCHIVED) => 'archive', + ], + 'constraints' => [ + new NotNull(), + new NotBlank(), + ], + ]) + ; + + if (false !== $referer && (new UnicodeString($referer))->startsWith('/')) { + $builder->add('referer', HiddenType::class, [ + 'data' => $referer, + ]); + } + + return $builder->getForm(); + } +} diff --git a/lib/Rozier/src/Controllers/Nodes/NodesUtilsController.php b/lib/Rozier/src/Controllers/Nodes/NodesUtilsController.php new file mode 100644 index 00000000..a25fa9bd --- /dev/null +++ b/lib/Rozier/src/Controllers/Nodes/NodesUtilsController.php @@ -0,0 +1,84 @@ +nodeNamePolicy = $nodeNamePolicy; + } + + /** + * Duplicate node by ID + * + * @param Request $request + * @param int $nodeId + * + * @return Response + */ + public function duplicateAction(Request $request, int $nodeId) + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_NODES'); + + /** @var Node $existingNode */ + $existingNode = $this->em()->find(Node::class, $nodeId); + + try { + $duplicator = new NodeDuplicator( + $existingNode, + $this->em(), + $this->nodeNamePolicy + ); + $newNode = $duplicator->duplicate(); + + /* + * Dispatch event + */ + $this->dispatchEvent(new NodeCreatedEvent($newNode)); + $this->dispatchEvent(new NodeDuplicatedEvent($newNode)); + + $msg = $this->getTranslator()->trans("duplicated.node.%name%", [ + '%name%' => $existingNode->getNodeName(), + ]); + + $this->publishConfirmMessage($request, $msg, $newNode->getNodeSources()->first()); + + return $this->redirectToRoute( + 'nodesEditPage', + ["nodeId" => $newNode->getId()] + ); + } catch (\Exception $e) { + $this->publishErrorMessage( + $request, + $this->getTranslator()->trans("impossible.duplicate.node.%name%", [ + '%name%' => $existingNode->getNodeName(), + ]) + ); + + return $this->redirectToRoute( + 'nodesEditPage', + ["nodeId" => $existingNode->getId()] + ); + } + } +} diff --git a/lib/Rozier/src/Controllers/Nodes/TranstypeController.php b/lib/Rozier/src/Controllers/Nodes/TranstypeController.php new file mode 100644 index 00000000..de1a7f3b --- /dev/null +++ b/lib/Rozier/src/Controllers/Nodes/TranstypeController.php @@ -0,0 +1,112 @@ +nodeTranstyper = $nodeTranstyper; + } + + /** + * @param Request $request + * @param int $nodeId + * + * @return RedirectResponse|Response + * @throws RuntimeError + * @throws \Exception + */ + public function transtypeAction(Request $request, int $nodeId) + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_NODES'); + + /** @var Node|null $node */ + $node = $this->em()->find(Node::class, $nodeId); + $this->em()->refresh($node); + + if (null === $node) { + throw new ResourceNotFoundException(); + } + + $form = $this->createForm(TranstypeType::class, null, [ + 'currentType' => $node->getNodeType(), + ]); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $data = $form->getData(); + + /** @var NodeType $newNodeType */ + $newNodeType = $this->em()->find(NodeType::class, (int) $data['nodeTypeId']); + + /* + * Trans-typing SHOULD be executed in one single transaction + * @see https://www.doctrine-project.org/projects/doctrine-orm/en/latest/reference/transactions-and-concurrency.html + */ + $this->em()->getConnection()->beginTransaction(); // suspend auto-commit + try { + $this->nodeTranstyper->transtype($node, $newNodeType); + $this->em()->flush(); + $this->em()->getConnection()->commit(); + } catch (\Exception $e) { + $this->em()->getConnection()->rollBack(); + throw $e; + } + + $this->em()->refresh($node); + /* + * Dispatch event + */ + $this->dispatchEvent(new NodeUpdatedEvent($node)); + + foreach ($node->getNodeSources() as $nodeSource) { + $this->dispatchEvent(new NodesSourcesUpdatedEvent($nodeSource)); + } + + $msg = $this->getTranslator()->trans('%node%.transtyped_to.%type%', [ + '%node%' => $node->getNodeName(), + '%type%' => $newNodeType->getName(), + ]); + $this->publishConfirmMessage($request, $msg, $node->getNodeSources()->first()); + + return $this->redirectToRoute( + 'nodesEditSourcePage', + [ + 'nodeId' => $node->getId(), + 'translationId' => $node->getNodeSources()->first()->getTranslation()->getId(), + ] + ); + } + + $this->assignation['form'] = $form->createView(); + $this->assignation['node'] = $node; + $this->assignation['parentNode'] = $node->getParent(); + $this->assignation['type'] = $node->getNodeType(); + + return $this->render('@RoadizRozier/nodes/transtype.html.twig', $this->assignation); + } +} diff --git a/lib/Rozier/src/Controllers/Nodes/UrlAliasesController.php b/lib/Rozier/src/Controllers/Nodes/UrlAliasesController.php new file mode 100644 index 00000000..a9966545 --- /dev/null +++ b/lib/Rozier/src/Controllers/Nodes/UrlAliasesController.php @@ -0,0 +1,380 @@ +formFactory = $formFactory; + } + + /** + * Return aliases form for requested node. + * + * @param Request $request + * @param int $nodeId + * @param int|null $translationId + * + * @return Response + * @throws RuntimeError + */ + public function editAliasesAction(Request $request, int $nodeId, ?int $translationId = null): Response + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_NODES'); + + if (null === $translationId || $translationId < 1) { + $translation = $this->em()->getRepository(Translation::class)->findDefault(); + } else { + $translation = $this->em()->find(Translation::class, $translationId); + } + /** @var NodesSources|null $source */ + $source = $this->em() + ->getRepository(NodesSources::class) + ->setDisplayingAllNodesStatuses(true) + ->setDisplayingNotPublishedNodes(true) + ->findOneBy(['translation' => $translation, 'node.id' => $nodeId]); + + if ($source !== null && null !== $node = $source->getNode()) { + $redirections = $this->em() + ->getRepository(Redirection::class) + ->findBy([ + 'redirectNodeSource' => $node->getNodeSources()->toArray() + ]); + $uas = $this->em() + ->getRepository(UrlAlias::class) + ->findAllFromNode($node->getId()); + $availableTranslations = $this->em() + ->getRepository(Translation::class) + ->findAvailableTranslationsForNode($node); + + $this->assignation['node'] = $node; + $this->assignation['source'] = $source; + $this->assignation['aliases'] = []; + $this->assignation['redirections'] = []; + $this->assignation['translation'] = $translation; + $this->assignation['available_translations'] = $availableTranslations; + + /* + * SEO Form + */ + $seoForm = $this->createForm(NodeSourceSeoType::class, $source); + $seoForm->handleRequest($request); + if ($seoForm->isSubmitted() && $seoForm->isValid()) { + $this->em()->flush(); + $msg = $this->getTranslator()->trans('node.seo.updated'); + $this->publishConfirmMessage($request, $msg, $source); + /* + * Dispatch event + */ + $this->dispatchEvent(new NodesSourcesUpdatedEvent($source)); + return $this->redirectToRoute( + 'nodesEditSEOPage', + ['nodeId' => $node->getId(), 'translationId' => $translationId] + ); + } + + if (null !== $response = $this->handleAddRedirection($source, $request)) { + return $response; + } + /* + * each url alias edit form + */ + /** @var UrlAlias $alias */ + foreach ($uas as $alias) { + if (null !== $response = $this->handleSingleUrlAlias($alias, $request)) { + return $response; + } + } + + /** @var Redirection $redirection */ + foreach ($redirections as $redirection) { + if (null !== $response = $this->handleSingleRedirection($redirection, $request)) { + return $response; + } + } + + /* + * Main ADD url alias form + */ + $alias = new UrlAlias(); + $addAliasForm = $this->formFactory->createNamed( + 'add_urlalias_' . $node->getId(), + UrlAliasType::class, + $alias, + [ + 'with_translation' => true + ] + ); + $addAliasForm->handleRequest($request); + if ($addAliasForm->isSubmitted() && $addAliasForm->isValid()) { + try { + $alias = $this->addNodeUrlAlias($alias, $node, $addAliasForm->get('translation')->getData()); + $msg = $this->getTranslator()->trans('url_alias.%alias%.created.%translation%', [ + '%alias%' => $alias->getAlias(), + '%translation%' => $alias->getNodeSource()->getTranslation()->getName(), + ]); + $this->publishConfirmMessage($request, $msg, $source); + /* + * Dispatch event + */ + $this->dispatchEvent(new UrlAliasCreatedEvent($alias)); + + return $this->redirect($this->generateUrl( + 'nodesEditSEOPage', + ['nodeId' => $node->getId(), 'translationId' => $translationId] + ) . '#manage-aliases'); + } catch (EntityAlreadyExistsException $e) { + $addAliasForm->addError(new FormError($e->getMessage())); + } catch (NoTranslationAvailableException $e) { + $addAliasForm->addError(new FormError($e->getMessage())); + } + } + + $this->assignation['form'] = $addAliasForm->createView(); + $this->assignation['seoForm'] = $seoForm->createView(); + + return $this->render('@RoadizRozier/nodes/editAliases.html.twig', $this->assignation); + } + + throw new ResourceNotFoundException(); + } + + /** + * @param UrlAlias $alias + * @param Node $node + * @param Translation $translation + * @return UrlAlias + */ + private function addNodeUrlAlias(UrlAlias $alias, Node $node, Translation $translation): UrlAlias + { + /** @var NodesSources|null $nodeSource */ + $nodeSource = $this->em() + ->getRepository(NodesSources::class) + ->setDisplayingAllNodesStatuses(true) + ->setDisplayingNotPublishedNodes(true) + ->findOneBy(['node' => $node, 'translation' => $translation]); + + if ($nodeSource !== null) { + $alias->setNodeSource($nodeSource); + $this->em()->persist($alias); + $this->em()->flush(); + + return $alias; + } else { + $msg = $this->getTranslator()->trans('url_alias.no_translation.%translation%', [ + '%translation%' => $translation->getName() + ]); + throw new NoTranslationAvailableException($msg); + } + } + + /** + * @param UrlAlias $alias + * @param Request $request + * + * @return RedirectResponse|null + */ + private function handleSingleUrlAlias(UrlAlias $alias, Request $request): ?RedirectResponse + { + $editForm = $this->formFactory->createNamed( + 'edit_urlalias_' . $alias->getId(), + UrlAliasType::class, + $alias + ); + $deleteForm = $this->formFactory->createNamed('delete_urlalias_' . $alias->getId()); + // Match edit + $editForm->handleRequest($request); + if ($editForm->isSubmitted() && $editForm->isValid()) { + try { + try { + $this->em()->flush(); + $msg = $this->getTranslator()->trans( + 'url_alias.%alias%.updated', + ['%alias%' => $alias->getAlias()] + ); + $this->publishConfirmMessage($request, $msg, $alias->getNodeSource()); + /* + * Dispatch event + */ + $this->dispatchEvent(new UrlAliasUpdatedEvent($alias)); + /** @var Translation $translation */ + $translation = $alias->getNodeSource()->getTranslation(); + + return $this->redirect($this->generateUrl( + 'nodesEditSEOPage', + [ + 'nodeId' => $alias->getNodeSource()->getNode()->getId(), + 'translationId' => $translation->getId() + ] + ) . '#manage-aliases'); + } catch (\RuntimeException $exception) { + $editForm->addError(new FormError($exception->getMessage())); + } + } catch (EntityAlreadyExistsException $e) { + $editForm->addError(new FormError($e->getMessage())); + } + } + + // Match delete + $deleteForm->handleRequest($request); + if ($deleteForm->isSubmitted() && $deleteForm->isValid()) { + $this->em()->remove($alias); + $this->em()->flush(); + $msg = $this->getTranslator()->trans('url_alias.%alias%.deleted', ['%alias%' => $alias->getAlias()]); + $this->publishConfirmMessage($request, $msg, $alias->getNodeSource()); + + /* + * Dispatch event + */ + $this->dispatchEvent(new UrlAliasDeletedEvent($alias)); + + /** @var Translation $translation */ + $translation = $alias->getNodeSource()->getTranslation(); + + return $this->redirect($this->generateUrl( + 'nodesEditSEOPage', + [ + 'nodeId' => $alias->getNodeSource()->getNode()->getId(), + 'translationId' => $translation->getId() + ] + ) . '#manage-aliases'); + } + + $this->assignation['aliases'][] = [ + 'alias' => $alias, + 'editForm' => $editForm->createView(), + 'deleteForm' => $deleteForm->createView(), + ]; + + return null; + } + + /** + * @param NodesSources $source + * @param Request $request + * @return RedirectResponse|null + */ + private function handleAddRedirection(NodesSources $source, Request $request): ?RedirectResponse + { + $redirection = new Redirection(); + $redirection->setRedirectNodeSource($source); + $redirection->setType(Response::HTTP_MOVED_PERMANENTLY); + + $addForm = $this->formFactory->createNamed( + 'add_redirection', + RedirectionType::class, + $redirection, + [ + 'placeholder' => $this->generateUrl($source), + 'only_query' => true + ] + ); + + $addForm->handleRequest($request); + if ($addForm->isSubmitted() && $addForm->isValid()) { + $this->em()->persist($redirection); + $this->em()->flush(); + + /** @var Translation $translation */ + $translation = $redirection->getRedirectNodeSource()->getTranslation(); + + return $this->redirect($this->generateUrl( + 'nodesEditSEOPage', + [ + 'nodeId' => $redirection->getRedirectNodeSource()->getNode()->getId(), + 'translationId' => $translation->getId() + ] + ) . '#manage-redirections'); + } + + $this->assignation['addRedirection'] = $addForm->createView(); + + return null; + } + + /** + * @param Redirection $redirection + * @param Request $request + * @return RedirectResponse|null + */ + private function handleSingleRedirection(Redirection $redirection, Request $request): ?RedirectResponse + { + $editForm = $this->formFactory->createNamed( + 'edit_redirection_' . $redirection->getId(), + RedirectionType::class, + $redirection, + [ + 'only_query' => true + ] + ); + + /** @var Translation $translation */ + $translation = $redirection->getRedirectNodeSource()->getTranslation(); + + $deleteForm = $this->formFactory->createNamed('delete_redirection_' . $redirection->getId()); + + $editForm->handleRequest($request); + if ($editForm->isSubmitted() && $editForm->isValid()) { + $this->em()->flush(); + return $this->redirect($this->generateUrl( + 'nodesEditSEOPage', + [ + 'nodeId' => $redirection->getRedirectNodeSource()->getNode()->getId(), + 'translationId' => $translation->getId() + ] + ) . '#manage-redirections'); + } + + // Match delete + $deleteForm->handleRequest($request); + if ($deleteForm->isSubmitted() && $deleteForm->isValid()) { + $this->em()->remove($redirection); + $this->em()->flush(); + return $this->redirect($this->generateUrl( + 'nodesEditSEOPage', + [ + 'nodeId' => $redirection->getRedirectNodeSource()->getNode()->getId(), + 'translationId' => $translation->getId() + ] + ) . '#manage-redirections'); + } + $this->assignation['redirections'][] = [ + 'redirection' => $redirection, + 'editForm' => $editForm->createView(), + 'deleteForm' => $deleteForm->createView(), + ]; + + return null; + } +} diff --git a/lib/Rozier/src/Controllers/PingController.php b/lib/Rozier/src/Controllers/PingController.php new file mode 100644 index 00000000..898e2a31 --- /dev/null +++ b/lib/Rozier/src/Controllers/PingController.php @@ -0,0 +1,26 @@ +denyAccessUnlessGranted('ROLE_BACKEND_USER'); + return $this->renderJson(['Pong']); + } +} diff --git a/lib/Rozier/src/Controllers/RedirectionsController.php b/lib/Rozier/src/Controllers/RedirectionsController.php new file mode 100644 index 00000000..cd787e20 --- /dev/null +++ b/lib/Rozier/src/Controllers/RedirectionsController.php @@ -0,0 +1,104 @@ +getQuery(); + } + throw new \InvalidArgumentException('Item should be instance of ' . $this->getEntityClass()); + } + + /** + * @inheritDoc + */ + protected function getDefaultOrder(Request $request): array + { + return ['query' => 'ASC']; + } +} diff --git a/lib/Rozier/src/Controllers/RolesController.php b/lib/Rozier/src/Controllers/RolesController.php new file mode 100644 index 00000000..06a6c78e --- /dev/null +++ b/lib/Rozier/src/Controllers/RolesController.php @@ -0,0 +1,151 @@ +getRole(); + } + throw new \InvalidArgumentException('Item should be instance of ' . $this->getEntityClass()); + } + + /** + * @inheritDoc + */ + protected function getDefaultOrder(Request $request): array + { + return ['name' => 'ASC']; + } + + /** + * @inheritDoc + */ + protected function denyAccessUnlessItemGranted(PersistableInterface $item): void + { + if ($item instanceof Role) { + $this->denyAccessUnlessGranted($item->getRole()); + } + } + + /** + * @inheritDoc + */ + protected function createCreateEvent(PersistableInterface $item): ?Event + { + if ($item instanceof Role) { + return new PreCreatedRoleEvent($item); + } + return null; + } + + /** + * @inheritDoc + */ + protected function createUpdateEvent(PersistableInterface $item): ?Event + { + if ($item instanceof Role) { + return new PreUpdatedRoleEvent($item); + } + return null; + } + + /** + * @inheritDoc + */ + protected function createDeleteEvent(PersistableInterface $item): ?Event + { + if ($item instanceof Role) { + return new PreDeletedRoleEvent($item); + } + return null; + } +} diff --git a/lib/Rozier/src/Controllers/RolesUtilsController.php b/lib/Rozier/src/Controllers/RolesUtilsController.php new file mode 100644 index 00000000..382baaf3 --- /dev/null +++ b/lib/Rozier/src/Controllers/RolesUtilsController.php @@ -0,0 +1,136 @@ +serializer = $serializer; + $this->rolesImporter = $rolesImporter; + } + + /** + * Export a Role in a Json file + * + * @param Request $request + * @param int $id + * + * @return Response + */ + public function exportAction(Request $request, int $id): Response + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_ROLES'); + + /** @var Role|null $existingRole */ + $existingRole = $this->em()->find(Role::class, $id); + + if (null === $existingRole) { + throw $this->createNotFoundException(); + } + + return new JsonResponse( + $this->serializer->serialize( + [$existingRole], + 'json', + SerializationContext::create()->setGroups(['role']) + ), + Response::HTTP_OK, + [ + 'Content-Disposition' => sprintf('attachment; filename="%s"', 'role-' . $existingRole->getName() . '-' . date("YmdHis") . '.json'), + ], + true + ); + } + + /** + * Import a Json file containing Roles. + * + * @param Request $request + * + * @return Response + * @throws RuntimeError + */ + public function importJsonFileAction(Request $request): Response + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_ROLES'); + + $form = $this->buildImportJsonFileForm(); + $form->handleRequest($request); + + if ( + $form->isSubmitted() && + $form->isValid() && + !empty($form['role_file']) + ) { + $file = $form['role_file']->getData(); + + if ($file->isValid()) { + $serializedData = file_get_contents($file->getPathname()); + + if (null !== \json_decode($serializedData)) { + if ($this->rolesImporter->import($serializedData)) { + $msg = $this->getTranslator()->trans('role.imported'); + $this->publishConfirmMessage($request, $msg); + + $this->em()->flush(); + + // Clear result cache + $cacheDriver = $this->em()->getConfiguration()->getResultCacheImpl(); + if ($cacheDriver instanceof CacheProvider) { + $cacheDriver->deleteAll(); + } + + // redirect even if its null + return $this->redirectToRoute( + 'rolesHomePage' + ); + } + } + $form->addError(new FormError($this->getTranslator()->trans('file.format.not_valid'))); + } else { + $form->addError(new FormError($this->getTranslator()->trans('file.not_uploaded'))); + } + } + + $this->assignation['form'] = $form->createView(); + + return $this->render('@RoadizRozier/roles/import.html.twig', $this->assignation); + } + + /** + * @return FormInterface + */ + private function buildImportJsonFileForm(): FormInterface + { + $builder = $this->createFormBuilder() + ->add('role_file', FileType::class, [ + 'label' => 'role.file', + ]); + + return $builder->getForm(); + } +} diff --git a/lib/Rozier/src/Controllers/SearchController.php b/lib/Rozier/src/Controllers/SearchController.php new file mode 100644 index 00000000..923cedaf --- /dev/null +++ b/lib/Rozier/src/Controllers/SearchController.php @@ -0,0 +1,690 @@ +isBlank($var); + } + + /** + * @param array $data + * @param string $fieldName + * + * @return array + */ + protected function appendDateTimeCriteria(array &$data, string $fieldName) + { + $date = $data[$fieldName]['compareDatetime']; + if ($date instanceof DateTime) { + $date = $date->format('Y-m-d H:i:s'); + } + $data[$fieldName] = [ + $data[$fieldName]['compareOp'], + $date, + ]; + return $data; + } + + /** + * @param array $data + * @param string $prefix + * @return mixed + */ + protected function processCriteria($data, string $prefix = "") + { + if (!empty($data[$prefix . "nodeName"])) { + if (isset($data[$prefix . "nodeName_exact"]) && $data[$prefix . "nodeName_exact"] === true) { + $data[$prefix . "nodeName"] = $data[$prefix . "nodeName"]; + } else { + $data[$prefix . "nodeName"] = ["LIKE", "%" . $data[$prefix . "nodeName"] . "%"]; + } + } + + if (key_exists($prefix . "nodeName_exact", $data)) { + unset($data[$prefix . "nodeName_exact"]); + } + + if (isset($data[$prefix . 'parent']) && !$this->isBlank($data[$prefix . "parent"])) { + if ($data[$prefix . "parent"] == "null" || $data[$prefix . "parent"] == 0) { + $data[$prefix . "parent"] = null; + } + } + + if (isset($data[$prefix . 'visible'])) { + $data[$prefix . 'visible'] = (bool) $data[$prefix . 'visible']; + } + + if (isset($data[$prefix . 'createdAt'])) { + $this->appendDateTimeCriteria($data, $prefix . 'createdAt'); + } + + if (isset($data[$prefix . 'updatedAt'])) { + $this->appendDateTimeCriteria($data, $prefix . 'updatedAt'); + } + + if (isset($data[$prefix . "limitResult"])) { + $this->pagination = false; + $this->itemPerPage = (int) $data[$prefix . "limitResult"]; + unset($data[$prefix . "limitResult"]); + } + + /* + * no need to prefix tags + */ + if (isset($data["tags"])) { + $data["tags"] = array_map('trim', explode(',', $data["tags"])); + foreach ($data["tags"] as $key => $value) { + $data["tags"][$key] = $this->em()->getRepository(Tag::class)->findByPath($value); + } + array_filter($data["tags"]); + } + + return $data; + } + + /** + * @param array|\Traversable $data + * @param NodeType $nodetype + * @return mixed + */ + protected function processCriteriaNodetype($data, NodeType $nodetype) + { + $fields = $nodetype->getFields(); + foreach ($data as $key => $value) { + if ($key === 'title') { + $data['title'] = ["LIKE", "%" . $value . "%"]; + if (isset($data[$key . '_exact'])) { + if ($data[$key . '_exact'] === true) { + $data['title'] = $value; + } + } + } elseif ($key === 'publishedAt') { + $this->appendDateTimeCriteria($data, 'publishedAt'); + } else { + /** @var NodeTypeField $field */ + foreach ($fields as $field) { + if ($key == $field->getName()) { + if ( + $field->getType() === AbstractField::MARKDOWN_T + || $field->getType() === AbstractField::STRING_T + || $field->getType() === AbstractField::YAML_T + || $field->getType() === AbstractField::JSON_T + || $field->getType() === AbstractField::TEXT_T + || $field->getType() === AbstractField::EMAIL_T + || $field->getType() === AbstractField::CSS_T + ) { + $data[$field->getVarName()] = ["LIKE", "%" . $value . "%"]; + if (isset($data[$key . '_exact']) && $data[$key . '_exact'] === true) { + $data[$field->getVarName()] = $value; + } + } elseif ($field->getType() === AbstractField::BOOLEAN_T) { + $data[$field->getVarName()] = (bool) $value; + } elseif ($field->getType() === AbstractField::MULTIPLE_T) { + $data[$field->getVarName()] = implode(",", $value); + } elseif ($field->getType() === AbstractField::DATETIME_T) { + $this->appendDateTimeCriteria($data, $key); + } elseif ($field->getType() === AbstractField::DATE_T) { + $this->appendDateTimeCriteria($data, $key); + } + } + } + } + if (key_exists($key . '_exact', $data)) { + unset($data[$key . '_exact']); + } + } + return $data; + } + + /** + * @param Request $request + * @return Response + * @throws RuntimeError + */ + public function searchNodeAction(Request $request) + { + $builder = $this->buildSimpleForm(''); + $form = $this->addButtons($builder)->getForm(); + $form->handleRequest($request); + + $builderNodeType = $this->buildNodeTypeForm(); + + /** @var Form $nodeTypeForm */ + $nodeTypeForm = $builderNodeType->getForm(); + $nodeTypeForm->handleRequest($request); + + if (null !== $response = $this->handleNodeTypeForm($nodeTypeForm)) { + $response->prepare($request); + return $response->send(); + } + + if ($form->isSubmitted() && $form->isValid()) { + $data = []; + foreach ($form->getData() as $key => $value) { + if ( + (!is_array($value) && $this->notBlank($value)) || + (is_array($value) && isset($value["compareDatetime"])) + ) { + $data[$key] = $value; + } + } + $data = $this->processCriteria($data); + $listManager = $this->createEntityListManager( + Node::class, + $data + ); + $listManager->setDisplayingNotPublishedNodes(true); + $listManager->setDisplayingAllNodesStatuses(true); + + if ($this->pagination === false) { + $listManager->setItemPerPage($this->itemPerPage ?? 999); + $listManager->disablePagination(); + } + $listManager->handle(); + + $this->assignation['filters'] = $listManager->getAssignation(); + $this->assignation['nodes'] = $listManager->getEntities(); + } + + $this->assignation['form'] = $form->createView(); + $this->assignation['nodeTypeForm'] = $nodeTypeForm->createView(); + $this->assignation['filters']['searchDisable'] = true; + + return $this->render('@RoadizRozier/search/list.html.twig', $this->assignation); + } + + /** + * @param Request $request + * @param int $nodetypeId + * + * @return null|RedirectResponse|Response + * @throws Exception + * @throws \PhpOffice\PhpSpreadsheet\Writer\Exception + * @throws RuntimeError + */ + public function searchNodeSourceAction(Request $request, int $nodetypeId) + { + /** @var NodeType|null $nodetype */ + $nodetype = $this->em()->find(NodeType::class, $nodetypeId); + + $builder = $this->buildSimpleForm("__node__"); + $this->extendForm($builder, $nodetype); + $this->addButtons($builder, true); + + /** @var Form $form */ + $form = $builder->getForm(); + $form->handleRequest($request); + + $builderNodeType = $this->buildNodeTypeForm($nodetypeId); + $nodeTypeForm = $builderNodeType->getForm(); + $nodeTypeForm->handleRequest($request); + + if (null !== $response = $this->handleNodeTypeForm($nodeTypeForm)) { + return $response; + } + + if (null !== $response = $this->handleNodeForm($form, $nodetype)) { + return $response; + } + + $this->assignation['form'] = $form->createView(); + $this->assignation['nodeType'] = $nodetype; + $this->assignation['filters']['searchDisable'] = true; + + return $this->render('@RoadizRozier/search/list.html.twig', $this->assignation); + } + + /** + * Build node-type selection form. + * + * @param int|null $nodetypeId + * @return FormBuilderInterface + */ + protected function buildNodeTypeForm(?int $nodetypeId = null): FormBuilderInterface + { + $builderNodeType = $this->createNamedFormBuilder('nodeTypeForm', [], ["method" => "get"]); + $builderNodeType->add( + "nodetype", + NodeTypesType::class, + [ + 'label' => 'nodeType', + 'placeholder' => "ignore", + 'required' => false, + 'data' => $nodetypeId, + 'showInvisible' => true, + ] + ); + + return $builderNodeType; + } + + /** + * @param FormBuilderInterface $builder + * @param bool $exportXlsx + * + * @return FormBuilderInterface + */ + protected function addButtons(FormBuilderInterface $builder, bool $exportXlsx = false): FormBuilderInterface + { + $builder->add('search', SubmitType::class, [ + 'label' => 'search.a.node', + 'attr' => [ + 'class' => 'uk-button uk-button-primary', + ], + ]); + + if ($exportXlsx) { + $builder->add('export', SubmitType::class, [ + 'label' => 'export.all.nodesSource', + 'attr' => [ + 'class' => 'uk-button rz-no-ajax', + ], + ]); + } + + return $builder; + } + + /** + * @param FormInterface $nodeTypeForm + * + * @return null|RedirectResponse + */ + protected function handleNodeTypeForm(FormInterface $nodeTypeForm) + { + if ($nodeTypeForm->isSubmitted() && $nodeTypeForm->isValid()) { + if (empty($nodeTypeForm->getData()['nodetype'])) { + return $this->redirectToRoute('searchNodePage'); + } else { + return $this->redirectToRoute( + 'searchNodeSourcePage', + [ + "nodetypeId" => $nodeTypeForm->getData()['nodetype'], + ] + ); + } + } + + return null; + } + + /** + * @param FormInterface $form + * @param NodeType $nodetype + * + * @return null|Response + * @throws Exception + * @throws \PhpOffice\PhpSpreadsheet\Writer\Exception + */ + protected function handleNodeForm(FormInterface $form, NodeType $nodetype): ?Response + { + if ($form->isSubmitted() && $form->isValid()) { + $data = []; + foreach ($form->getData() as $key => $value) { + if ( + (!is_array($value) && $this->notBlank($value)) + || (is_array($value) && isset($value["compareDatetime"])) + || (is_array($value) && isset($value["compareDate"])) + || (is_array($value) && $value != [] && !isset($value["compareOp"])) + ) { + if (strstr($key, "__node__") == 0) { + $data[str_replace("__node__", "node.", $key)] = $value; + } else { + $data[$key] = $value; + } + } + } + $data = $this->processCriteria($data, "node."); + $data = $this->processCriteriaNodetype($data, $nodetype); + + $listManager = $this->createEntityListManager( + $nodetype->getSourceEntityFullQualifiedClassName(), + $data + ); + $listManager->setDisplayingNotPublishedNodes(true); + $listManager->setDisplayingAllNodesStatuses(true); + if ($this->pagination === false) { + $listManager->setItemPerPage($this->itemPerPage ?? 999); + $listManager->disablePagination(); + } + $listManager->handle(); + $entities = $listManager->getEntities(); + $nodes = []; + foreach ($entities as $nodesSource) { + if (!in_array($nodesSource->getNode(), $nodes)) { + $nodes[] = $nodesSource->getNode(); + } + } + /* + * Export all entries into XLSX format + */ + $button = $form->get('export'); + if ($button instanceof ClickableInterface && $button->isClicked()) { + $response = new Response( + $this->getXlsxResults($nodetype, $entities), + Response::HTTP_OK, + [] + ); + + $response->headers->set( + 'Content-Disposition', + $response->headers->makeDisposition( + ResponseHeaderBag::DISPOSITION_ATTACHMENT, + 'search.xlsx' + ) + ); + + return $response; + } + + $this->assignation['filters'] = $listManager->getAssignation(); + $this->assignation['nodesSources'] = $entities; + $this->assignation['nodes'] = $nodes; + } + + return null; + } + + /** + * @param NodeType $nodetype + * @param array|IteratorAggregate $entities + * + * @return string + * @throws Exception + * @throws \PhpOffice\PhpSpreadsheet\Writer\Exception + */ + protected function getXlsxResults(NodeType $nodetype, $entities): string + { + $fields = $nodetype->getFields(); + $keys = []; + $answers = []; + $keys[] = "title"; + /** @var NodeTypeField $field */ + foreach ($fields as $field) { + if (!$field->isVirtual() && !$field->isCollection()) { + $keys[] = $field->getName(); + } + } + foreach ($entities as $idx => $nodesSource) { + $array = []; + foreach ($keys as $key) { + $getter = 'get' . str_replace('_', '', ucwords($key)); + $tmp = $nodesSource->$getter(); + if (is_array($tmp)) { + $tmp = implode(',', $tmp); + } + $array[] = $tmp; + } + $answers[$idx] = $array; + } + + $exporter = new XlsxExporter($this->getTranslator()); + return $exporter->exportXlsx($answers, $keys); + } + + /** + * @param string $prefix + * @return FormBuilderInterface + */ + protected function buildSimpleForm(string $prefix = ''): FormBuilderInterface + { + /** @var FormBuilder $builder */ + $builder = $this->createFormBuilder([], ["method" => "get"]); + + $builder->add($prefix . 'status', NodeStatesType::class, [ + 'label' => 'node.status', + 'required' => false, + ]); + $builder->add( + $builder->create('status_group', FormType::class, [ + 'label' => false, + 'inherit_data' => true, + 'mapped' => false, + 'attr' => [ + 'class' => 'form-col-status-group', + ], + ]) + ->add($prefix . 'visible', ExtendedBooleanType::class, [ + 'label' => 'visible', + ]) + ->add($prefix . 'locked', ExtendedBooleanType::class, [ + 'label' => 'locked', + ]) + ->add($prefix . 'sterile', ExtendedBooleanType::class, [ + 'label' => 'sterile-status', + ]) + ->add($prefix . 'hideChildren', ExtendedBooleanType::class, [ + 'label' => 'hiding-children', + ]) + ); + $builder->add( + $this->createTextSearchForm($builder, $prefix . 'nodeName', 'nodeName') + ); + $builder->add($prefix . 'parent', TextType::class, [ + 'label' => 'node.id.parent', + 'required' => false, + ]) + ->add($prefix . 'createdAt', CompareDatetimeType::class, [ + 'label' => 'created.at', + 'inherit_data' => false, + 'required' => false, + ]) + ->add($prefix . 'updatedAt', CompareDatetimeType::class, [ + 'label' => 'updated.at', + 'inherit_data' => false, + 'required' => false, + ]) + ->add($prefix . 'limitResult', NumberType::class, [ + 'label' => 'node.limit.result', + 'required' => false, + 'constraints' => [ + new GreaterThan(0), + ], + ]) + // No need to prefix tags + ->add('tags', TextType::class, [ + 'label' => 'node.tags', + 'required' => false, + 'attr' => ['class' => 'rz-tag-autocomplete'], + ]) + // No need to prefix tags + ->add('tagExclusive', CheckboxType::class, [ + 'label' => 'node.tag.exclusive', + 'required' => false, + ]) + ; + + return $builder; + } + + /** + * @param FormBuilderInterface $builder + * @param string $formName + * @param string $label + * + * @return FormBuilderInterface + */ + protected function createTextSearchForm( + FormBuilderInterface $builder, + string $formName, + string $label + ): FormBuilderInterface { + return $builder->create($formName . '_group', FormType::class, [ + 'label' => false, + 'inherit_data' => true, + 'mapped' => false, + 'attr' => [ + 'class' => 'form-col-search-group', + ], + ]) + ->add($formName, TextType::class, [ + 'label' => $label, + 'required' => false, + ]) + ->add($formName . '_exact', CheckboxType::class, [ + 'label' => 'exact_search', + 'required' => false, + ]) + ; + } + + /** + * @param FormBuilderInterface $builder + * @param NodeType $nodetype + * @return FormBuilderInterface + */ + private function extendForm(FormBuilderInterface $builder, NodeType $nodetype): FormBuilderInterface + { + $fields = $nodetype->getFields(); + + $builder->add( + "nodetypefield", + SeparatorType::class, + [ + 'label' => 'nodetypefield', + 'attr' => ["class" => "label-separator"], + ] + ); + $builder->add( + $this->createTextSearchForm($builder, 'title', 'title') + ); + if ($nodetype->isPublishable()) { + $builder->add( + "publishedAt", + CompareDatetimeType::class, + [ + 'label' => 'publishedAt', + 'required' => false, + ] + ); + } + + + /** @var NodeTypeField $field */ + foreach ($fields as $field) { + $option = ["label" => $field->getLabel()]; + $option['required'] = false; + if ($field->isVirtual()) { + continue; + } + /* + * Prevent searching on complex fields + */ + if ( + $field->isMultipleProvider() || + $field->isSingleProvider() || + $field->isCollection() || + $field->isManyToMany() || + $field->isManyToOne() + ) { + continue; + } + + if ($field->getType() === AbstractField::ENUM_T) { + $choices = explode(',', $field->getDefaultValues() ?? ''); + $choices = array_map('trim', $choices); + $choices = array_combine(array_values($choices), array_values($choices)); + $type = ChoiceType::class; + $option['placeholder'] = 'ignore'; + $option['required'] = false; + $option["expanded"] = false; + if (count($choices) < 4) { + $option["expanded"] = true; + } + $option["choices"] = $choices; + } elseif ($field->getType() === AbstractField::MULTIPLE_T) { + $choices = explode(',', $field->getDefaultValues() ?? ''); + $choices = array_map('trim', $choices); + $choices = array_combine(array_values($choices), array_values($choices)); + $type = ChoiceType::class; + $option["choices"] = $choices; + $option['placeholder'] = 'ignore'; + $option['required'] = false; + $option["multiple"] = true; + $option["expanded"] = false; + if (count($choices) < 4) { + $option["expanded"] = true; + } + } elseif ($field->getType() === AbstractField::DATETIME_T) { + $type = CompareDatetimeType::class; + } elseif ($field->getType() === AbstractField::DATE_T) { + $type = CompareDateType::class; + } else { + $type = NodeSourceType::getFormTypeFromFieldType($field); + } + + if ( + $field->getType() === AbstractField::MARKDOWN_T || + $field->getType() === AbstractField::STRING_T || + $field->getType() === AbstractField::TEXT_T || + $field->getType() === AbstractField::EMAIL_T || + $field->getType() === AbstractField::JSON_T || + $field->getType() === AbstractField::YAML_T || + $field->getType() === AbstractField::CSS_T + ) { + $builder->add( + $this->createTextSearchForm($builder, $field->getVarName(), $field->getLabel()) + ); + } else { + $builder->add($field->getVarName(), $type, $option); + } + } + return $builder; + } +} diff --git a/lib/Rozier/src/Controllers/SettingGroupsController.php b/lib/Rozier/src/Controllers/SettingGroupsController.php new file mode 100644 index 00000000..5f87b52d --- /dev/null +++ b/lib/Rozier/src/Controllers/SettingGroupsController.php @@ -0,0 +1,104 @@ +getName(); + } + throw new \InvalidArgumentException('Item should be instance of ' . $this->getEntityClass()); + } + + /** + * @inheritDoc + */ + protected function getDefaultOrder(Request $request): array + { + return ['name' => 'ASC']; + } +} diff --git a/lib/Rozier/src/Controllers/SettingsController.php b/lib/Rozier/src/Controllers/SettingsController.php new file mode 100644 index 00000000..81284ef6 --- /dev/null +++ b/lib/Rozier/src/Controllers/SettingsController.php @@ -0,0 +1,337 @@ +formFactory = $formFactory; + $this->formErrorSerializer = $formErrorSerializer; + } + + /** + * List every setting. + * + * @param Request $request + * + * @return Response + * @throws RuntimeError + */ + public function indexAction(Request $request): Response + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_SETTINGS'); + + if (null !== $response = $this->commonSettingList($request)) { + return $response->send(); + } + + return $this->render('@RoadizRozier/settings/list.html.twig', $this->assignation); + } + + /** + * @param Request $request + * @param int $settingGroupId + * + * @return Response + * @throws RuntimeError + */ + public function byGroupAction(Request $request, int $settingGroupId): Response + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_SETTINGS'); + + /** @var SettingGroup|null $settingGroup */ + $settingGroup = $this->em()->find(SettingGroup::class, $settingGroupId); + + if ($settingGroup === null) { + throw new ResourceNotFoundException(); + } + + $this->assignation['settingGroup'] = $settingGroup; + + if (null !== $response = $this->commonSettingList($request, $settingGroup)) { + return $response->send(); + } + + return $this->render('@RoadizRozier/settings/list.html.twig', $this->assignation); + } + + /** + * @param Request $request + * @param SettingGroup|null $settingGroup + * + * @return Response|null + */ + protected function commonSettingList(Request $request, SettingGroup $settingGroup = null): ?Response + { + $criteria = []; + if (null !== $settingGroup) { + $criteria = ['settingGroup' => $settingGroup]; + } + /* + * Manage get request to filter list + */ + $listManager = $this->createEntityListManager( + Setting::class, + $criteria, + ['name' => 'ASC'] + ); + $listManager->setDisplayingNotPublishedNodes(true); + + /* + * Stored in session + */ + $sessionListFilter = new SessionListFilters('settings_item_per_page'); + $sessionListFilter->handleItemPerPage($request, $listManager); + + $listManager->handle(); + + $this->assignation['filters'] = $listManager->getAssignation(); + $settings = $listManager->getEntities(); + $this->assignation['settings'] = []; + + /** @var Setting $setting */ + foreach ($settings as $setting) { + $form = $this->formFactory->createNamed($setting->getName(), SettingType::class, $setting, [ + 'shortEdit' => true, + ]); + $form->handleRequest($request); + if ($form->isSubmitted()) { + if ($form->isValid()) { + try { + $this->resetSettingsCache(); + $this->dispatchEvent(new SettingUpdatedEvent($setting)); + $this->em()->flush(); + $msg = $this->getTranslator()->trans( + 'setting.%name%.updated', + ['%name%' => $setting->getName()] + ); + $this->publishConfirmMessage($request, $msg); + + if ($request->isXmlHttpRequest() || $request->getRequestFormat('html') === 'json') { + return new JsonResponse([ + 'status' => 'success', + 'message' => $msg, + ], Response::HTTP_ACCEPTED); + } + + if (null !== $settingGroup) { + return $this->redirectToRoute( + 'settingGroupsSettingsPage', + ['settingGroupId' => $settingGroup->getId()] + ); + } else { + return $this->redirectToRoute( + 'settingsHomePage' + ); + } + } catch (\RuntimeException $e) { + $form->addError(new FormError($e->getMessage())); + } + } + // Form can be invalidated during persistance process + if (!$form->isValid()) { + $errors = $this->formErrorSerializer->getErrorsAsArray($form); + /* + * Do not publish any message, it may lead to flushing invalid form + */ + if ($request->isXmlHttpRequest() || $request->getRequestFormat('html') === 'json') { + return new JsonResponse([ + 'status' => 'failed', + 'errors' => $errors, + ], Response::HTTP_BAD_REQUEST); + } + } + } + + $document = null; + if ($setting->getType() == AbstractField::DOCUMENTS_T) { + $document = $this->getSettingsBag()->getDocument($setting->getName()); + } + + $this->assignation['settings'][] = [ + 'setting' => $setting, + 'form' => $form->createView(), + 'document' => $document, + ]; + } + + return null; + } + + /** + * Return an edition form for requested setting. + * + * @param Request $request + * @param int $settingId + * + * @return Response + * @throws RuntimeError + */ + public function editAction(Request $request, int $settingId): Response + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_SETTINGS'); + /** @var Setting|null $setting */ + $setting = $this->em()->find(Setting::class, $settingId); + + if ($setting === null) { + throw $this->createNotFoundException(); + } + + $this->assignation['setting'] = $setting; + + $form = $this->createForm(SettingType::class, $setting, [ + 'shortEdit' => false + ]); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + try { + $this->resetSettingsCache(); + $this->dispatchEvent(new SettingUpdatedEvent($setting)); + $this->em()->flush(); + $msg = $this->getTranslator()->trans('setting.%name%.updated', ['%name%' => $setting->getName()]); + $this->publishConfirmMessage($request, $msg); + /* + * Force redirect to avoid resending form when refreshing page + */ + return $this->redirectToRoute( + 'settingsEditPage', + ['settingId' => $setting->getId()] + ); + } catch (\RuntimeException $e) { + $form->addError(new FormError($e->getMessage())); + } + } + + $this->assignation['form'] = $form->createView(); + + return $this->render('@RoadizRozier/settings/edit.html.twig', $this->assignation); + } + + protected function resetSettingsCache(): void + { + $this->getSettingsBag()->reset(); + /** @var CacheProvider|null $cacheDriver */ + $cacheDriver = $this->em()->getConfiguration()->getResultCacheImpl(); + $cacheDriver?->deleteAll(); + $this->dispatchEvent(new CachePurgeRequestEvent()); + } + + /** + * Return a creation form for requested setting. + * + * @param Request $request + * + * @return Response + * @throws RuntimeError + */ + public function addAction(Request $request): Response + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_SETTINGS'); + + $setting = new Setting(); + $setting->setSettingGroup(null); + + $this->assignation['setting'] = $setting; + $form = $this->createForm(SettingType::class, $setting, [ + 'shortEdit' => false, + ]); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + try { + $this->dispatchEvent(new SettingCreatedEvent($setting)); + $this->resetSettingsCache(); + $this->em()->persist($setting); + $this->em()->flush(); + $msg = $this->getTranslator()->trans('setting.%name%.created', ['%name%' => $setting->getName()]); + $this->publishConfirmMessage($request, $msg); + + return $this->redirectToRoute('settingsHomePage'); + } catch (EntityAlreadyExistsException $e) { + $form->addError(new FormError($e->getMessage())); + } + } + + $this->assignation['form'] = $form->createView(); + + return $this->render('@RoadizRozier/settings/add.html.twig', $this->assignation); + } + + /** + * Return a deletion form for requested setting. + * + * @param Request $request + * @param int $settingId + * + * @return Response + * @throws RuntimeError + */ + public function deleteAction(Request $request, int $settingId): Response + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_SETTINGS'); + + /** @var Setting|null $setting */ + $setting = $this->em()->find(Setting::class, $settingId); + + if (null === $setting) { + throw new ResourceNotFoundException(); + } + + $this->assignation['setting'] = $setting; + + $form = $this->createForm(FormType::class, $setting); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + try { + $this->dispatchEvent(new SettingDeletedEvent($setting)); + $this->resetSettingsCache(); + $this->em()->remove($setting); + $this->em()->flush(); + + $msg = $this->getTranslator()->trans('setting.%name%.deleted', ['%name%' => $setting->getName()]); + $this->publishConfirmMessage($request, $msg); + + /* + * Force redirect to avoid resending form when refreshing page + */ + return $this->redirectToRoute('settingsHomePage'); + } catch (\RuntimeException $e) { + $form->addError(new FormError($e->getMessage())); + } + } + + $this->assignation['form'] = $form->createView(); + + return $this->render('@RoadizRozier/settings/delete.html.twig', $this->assignation); + } +} diff --git a/lib/Rozier/src/Controllers/SettingsUtilsController.php b/lib/Rozier/src/Controllers/SettingsUtilsController.php new file mode 100644 index 00000000..75e61568 --- /dev/null +++ b/lib/Rozier/src/Controllers/SettingsUtilsController.php @@ -0,0 +1,141 @@ +serializer = $serializer; + $this->settingsImporter = $settingsImporter; + } + + /** + * Export all settings in a Json file. + * + * @param Request $request + * @param int|null $settingGroupId + * + * @return Response + */ + public function exportAllAction(Request $request, ?int $settingGroupId = null): Response + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_SETTINGS'); + + if (null !== $settingGroupId) { + /** @var SettingGroup|null $group */ + $group = $this->em()->find(SettingGroup::class, $settingGroupId); + if (null === $group) { + throw $this->createNotFoundException(); + } + $fileName = 'settings-' . strtolower(StringHandler::cleanForFilename($group->getName())) . '-' . date("YmdHis") . '.json'; + $settings = $this->em() + ->getRepository(Setting::class) + ->findBySettingGroup($group); + } else { + $fileName = 'settings-' . date("YmdHis") . '.json'; + $settings = $this->em() + ->getRepository(Setting::class) + ->findAll(); + } + + return new JsonResponse( + $this->serializer->serialize( + $settings, + 'json', + SerializationContext::create()->setGroups(['setting']) + ), + Response::HTTP_OK, + [ + 'Content-Disposition' => sprintf('attachment; filename="%s"', $fileName), + ], + true + ); + } + + /** + * Import a Json file (.rzt) containing setting and setting group. + * + * @param Request $request + * + * @return Response + * @throws RuntimeError + */ + public function importJsonFileAction(Request $request): Response + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_SETTINGS'); + + $form = $this->buildImportJsonFileForm(); + + $form->handleRequest($request); + + if ( + $form->isSubmitted() && + $form->isValid() && + !empty($form['setting_file']) + ) { + $file = $form['setting_file']->getData(); + + if ($file->isValid()) { + $serializedData = file_get_contents($file->getPathname()); + + if (null !== \json_decode($serializedData)) { + if ($this->settingsImporter->import($serializedData)) { + $msg = $this->getTranslator()->trans('setting.imported'); + $this->publishConfirmMessage($request, $msg); + $this->em()->flush(); + + // redirect even if its null + return $this->redirectToRoute( + 'settingsHomePage' + ); + } + } + $form->addError(new FormError($this->getTranslator()->trans('file.format.not_valid'))); + } else { + $form->addError(new FormError($this->getTranslator()->trans('file.not_uploaded'))); + } + } + + $this->assignation['form'] = $form->createView(); + + return $this->render('@RoadizRozier/settings/import.html.twig', $this->assignation); + } + + /** + * @return FormInterface + */ + private function buildImportJsonFileForm(): FormInterface + { + $builder = $this->createFormBuilder() + ->add('setting_file', FileType::class, [ + 'label' => 'settingFile', + ]); + + return $builder->getForm(); + } +} diff --git a/lib/Rozier/src/Controllers/Tags/TagMultiCreationController.php b/lib/Rozier/src/Controllers/Tags/TagMultiCreationController.php new file mode 100644 index 00000000..630fba1c --- /dev/null +++ b/lib/Rozier/src/Controllers/Tags/TagMultiCreationController.php @@ -0,0 +1,99 @@ +tagFactory = $tagFactory; + } + + /** + * @param Request $request + * @param int $parentTagId + * @return RedirectResponse|Response|null + * @throws \Twig\Error\RuntimeError + */ + public function addChildAction(Request $request, int $parentTagId) + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_TAGS'); + + $translation = $this->em()->getRepository(Translation::class)->findDefault(); + $parentTag = $this->em()->find(Tag::class, $parentTagId); + + if (null !== $parentTag) { + $form = $this->createForm(MultiTagType::class); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + try { + $data = $form->getData(); + $names = explode(',', $data['names']); + $names = array_map('trim', $names); + $names = array_filter($names); + $names = array_unique($names); + + /* + * Get latest position to add tags after. + */ + $latestPosition = $this->em() + ->getRepository(Tag::class) + ->findLatestPositionInParent($parentTag); + + $tagsArray = []; + foreach ($names as $name) { + $tagsArray[] = $this->tagFactory->create($name, $translation, $parentTag, $latestPosition); + $this->em()->flush(); + } + + /* + * Dispatch event and msg + */ + foreach ($tagsArray as $tag) { + /* + * Dispatch event + */ + $this->dispatchEvent(new TagCreatedEvent($tag)); + $msg = $this->getTranslator()->trans('child.tag.%name%.created', ['%name%' => $tag->getTagName()]); + $this->publishConfirmMessage($request, $msg); + } + + return $this->redirectToRoute('tagsTreePage', ['tagId' => $parentTagId]); + } catch (\InvalidArgumentException $e) { + $form->addError(new FormError($e->getMessage())); + } + } + + $this->assignation['translation'] = $translation; + $this->assignation['form'] = $form->createView(); + $this->assignation['tag'] = $parentTag; + + return $this->render('@RoadizRozier/tags/add-multiple.html.twig', $this->assignation); + } + + throw new ResourceNotFoundException(); + } +} diff --git a/lib/Rozier/src/Controllers/Tags/TagsController.php b/lib/Rozier/src/Controllers/Tags/TagsController.php new file mode 100644 index 00000000..659c18a5 --- /dev/null +++ b/lib/Rozier/src/Controllers/Tags/TagsController.php @@ -0,0 +1,733 @@ +handlerFactory = $handlerFactory; + $this->formFactory = $formFactory; + $this->treeWidgetFactory = $treeWidgetFactory; + } + + /** + * List every tags. + * + * @param Request $request + * + * @return Response + */ + public function indexAction(Request $request) + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_TAGS'); + + /* + * Manage get request to filter list + */ + $listManager = $this->createEntityListManager( + Tag::class + ); + $listManager->setDisplayingNotPublishedNodes(true); + $listManager->handle(); + + $this->assignation['filters'] = $listManager->getAssignation(); + $this->assignation['tags'] = $listManager->getEntities(); + + if ($this->isGranted('ROLE_ACCESS_TAGS_DELETE')) { + /* + * Handle bulk delete form + */ + $deleteTagsForm = $this->buildBulkDeleteForm($request->getRequestUri()); + $this->assignation['deleteTagsForm'] = $deleteTagsForm->createView(); + } + + return $this->render('@RoadizRozier/tags/list.html.twig', $this->assignation); + } + + /** + * Return an edition form for current translated tag. + * + * @param Request $request + * @param int $tagId + * @param int|null $translationId + * + * @return Response + * @throws RuntimeError + */ + public function editTranslatedAction(Request $request, int $tagId, ?int $translationId = null) + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_TAGS'); + + if (null === $translationId) { + /** @var Translation|null $translation */ + $translation = $this->em()->getRepository(Translation::class)->findDefault(); + } else { + /** @var Translation|null $translation */ + $translation = $this->em()->find(Translation::class, $translationId); + } + + if (null === $translation) { + throw new ResourceNotFoundException(); + } + /* + * Here we need to directly select tagTranslation + * if not doctrine will grab a cache tag because of TagTreeWidget + * that is initialized before calling route method. + */ + /** @var Tag|null $tag */ + $tag = $this->em()->find(Tag::class, $tagId); + + /** @var TagTranslation|null $tagTranslation */ + $tagTranslation = $this->em()->getRepository(TagTranslation::class) + ->findOneBy(['translation' => $translation, 'tag' => $tag]); + + if (null === $tag) { + throw new ResourceNotFoundException(); + } + + if (null === $tagTranslation) { + /* + * If translation does not exist, we created it. + */ + $this->em()->refresh($tag); + $baseTranslation = $tag->getTranslatedTags()->first(); + $tagTranslation = new TagTranslation($tag, $translation); + if (false !== $baseTranslation) { + $tagTranslation->setName($baseTranslation->getName()); + } else { + $tagTranslation->setName('tag_' . $tag->getId()); + } + $this->em()->persist($tagTranslation); + $this->em()->flush(); + } + + /** + * Versioning + */ + if ($this->isGranted('ROLE_ACCESS_VERSIONS')) { + if (null !== $response = $this->handleVersions($request, $tagTranslation)) { + return $response; + } + } + + $form = $this->createForm(TagTranslationType::class, $tagTranslation, [ + 'tagName' => $tag->getTagName(), + 'disabled' => $this->isReadOnly, + ]); + $form->handleRequest($request); + + if ($form->isSubmitted()) { + if ($form->isValid()) { + /* + * Update tag slug if not locked + * only from default translation. + */ + $newTagName = StringHandler::slugify($tagTranslation->getName()); + if ($tag->getTagName() !== $newTagName) { + if ( + !$tag->isLocked() && + $translation->isDefaultTranslation() && + !$this->tagNameExists($newTagName) + ) { + $tag->setTagName($tagTranslation->getName()); + } + } + $this->em()->flush(); + /* + * Dispatch event + */ + $this->dispatchEvent( + new TagUpdatedEvent($tag) + ); + + $msg = $this->getTranslator()->trans('tag.%name%.updated', [ + '%name%' => $tagTranslation->getName(), + ]); + $this->publishConfirmMessage($request, $msg); + + /* + * Force redirect to avoid resending form when refreshing page + */ + return $this->getPostUpdateRedirection($tagTranslation); + } + + /* + * Handle errors when Ajax POST requests + */ + if ($request->isXmlHttpRequest()) { + $errors = $this->getErrorsAsArray($form); + return new JsonResponse([ + 'status' => 'fail', + 'errors' => $errors, + 'message' => $this->getTranslator()->trans('form_has_errors.check_you_fields'), + ], JsonResponse::HTTP_BAD_REQUEST); + } + } + /** @var TranslationRepository $translationRepository */ + $translationRepository = $this->em()->getRepository(Translation::class); + + $this->assignation['tag'] = $tag; + $this->assignation['translation'] = $translation; + $this->assignation['translatedTag'] = $tagTranslation; + $this->assignation['available_translations'] = $translationRepository->findAll(); + $this->assignation['translations'] = $translationRepository->findAvailableTranslationsForTag($tag); + $this->assignation['form'] = $form->createView(); + $this->assignation['readOnly'] = $this->isReadOnly; + + return $this->render('@RoadizRozier/tags/edit.html.twig', $this->assignation); + } + + /** + * @param string $name + * + * @return bool + */ + protected function tagNameExists(string $name): bool + { + $entity = $this->em()->getRepository(Tag::class)->findOneByTagName($name); + + return (null !== $entity); + } + + /** + * @param Request $request + * + * @return Response + * @throws RuntimeError + */ + public function bulkDeleteAction(Request $request) + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_TAGS_DELETE'); + + if (!empty($request->get('deleteForm')['tagsIds'])) { + $tagsIds = trim($request->get('deleteForm')['tagsIds']); + $tagsIds = explode(',', $tagsIds); + array_filter($tagsIds); + + $tags = $this->em() + ->getRepository(Tag::class) + ->findBy([ + 'id' => $tagsIds, + ]); + + if (count($tags) > 0) { + $form = $this->buildBulkDeleteForm( + $request->get('deleteForm')['referer'], + $tagsIds + ); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $msg = $this->bulkDeleteTags($form->getData()); + + $this->publishConfirmMessage($request, $msg); + + if (!empty($form->getData()['referer'])) { + return $this->redirect($form->getData()['referer']); + } else { + return $this->redirectToRoute('tagsHomePage'); + } + } + + $this->assignation['tags'] = $tags; + $this->assignation['form'] = $form->createView(); + + if (!empty($request->get('deleteForm')['referer'])) { + $this->assignation['referer'] = $request->get('deleteForm')['referer']; + } + + return $this->render('@RoadizRozier/tags/bulkDelete.html.twig', $this->assignation); + } + } + + throw new ResourceNotFoundException(); + } + + /** + * @param Request $request + * + * @return Response + */ + public function addAction(Request $request) + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_TAGS'); + + $tag = new Tag(); + $translation = $this->em()->getRepository(Translation::class)->findDefault(); + + if ($translation !== null) { + $this->assignation['tag'] = $tag; + $form = $this->createForm(TagType::class, $tag); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + /* + * Get latest position to add tags after. + */ + $latestPosition = $this->em() + ->getRepository(Tag::class) + ->findLatestPositionInParent(); + $tag->setPosition($latestPosition + 1); + + $this->em()->persist($tag); + $this->em()->flush(); + + $translatedTag = new TagTranslation($tag, $translation); + $this->em()->persist($translatedTag); + $this->em()->flush(); + + /* + * Dispatch event + */ + $this->dispatchEvent(new TagCreatedEvent($tag)); + + $msg = $this->getTranslator()->trans('tag.%name%.created', ['%name%' => $tag->getTagName()]); + $this->publishConfirmMessage($request, $msg); + /* + * Force redirect to avoid resending form when refreshing page + */ + return $this->redirectToRoute('tagsHomePage'); + } + + $this->assignation['form'] = $form->createView(); + + return $this->render('@RoadizRozier/tags/add.html.twig', $this->assignation); + } + + throw new ResourceNotFoundException(); + } + + /** + * @param Request $request + * @param int $tagId + * + * @return Response + */ + public function editSettingsAction(Request $request, int $tagId) + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_TAGS'); + + $translation = $this->em()->getRepository(Translation::class)->findDefault(); + + /** @var Tag|null $tag */ + $tag = $this->em()->find(Tag::class, $tagId); + + if ($tag === null) { + throw new ResourceNotFoundException(); + } + + $form = $this->createForm(TagType::class, $tag, [ + 'tagName' => $tag->getTagName(), + ]); + + $form->handleRequest($request); + + if ($form->isSubmitted()) { + if ($form->isValid()) { + $this->em()->flush(); + /* + * Dispatch event + */ + $this->dispatchEvent(new TagUpdatedEvent($tag)); + + $msg = $this->getTranslator()->trans('tag.%name%.updated', ['%name%' => $tag->getTagName()]); + $this->publishConfirmMessage($request, $msg); + + /* + * Force redirect to avoid resending form when refreshing page + */ + return $this->redirectToRoute( + 'tagsSettingsPage', + ['tagId' => $tag->getId()] + ); + } + /* + * Handle errors when Ajax POST requests + */ + if ($request->isXmlHttpRequest()) { + $errors = $this->getErrorsAsArray($form); + return new JsonResponse([ + 'status' => 'fail', + 'errors' => $errors, + 'message' => $this->getTranslator()->trans('form_has_errors.check_you_fields'), + ], JsonResponse::HTTP_BAD_REQUEST); + } + } + + $this->assignation['form'] = $form->createView(); + $this->assignation['tag'] = $tag; + $this->assignation['translation'] = $translation; + + return $this->render('@RoadizRozier/tags/settings.html.twig', $this->assignation); + } + + /** + * @param Request $request + * @param int $tagId + * @param int|null $translationId + * + * @return Response + */ + public function treeAction(Request $request, int $tagId, ?int $translationId = null) + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_TAGS'); + + $tag = $this->em() + ->find(Tag::class, $tagId); + $this->em()->refresh($tag); + + if (null !== $translationId) { + $translation = $this->em() + ->getRepository(Translation::class) + ->findOneBy(['id' => $translationId]); + } else { + $translation = $this->em()->getRepository(Translation::class)->findDefault(); + } + + if (null !== $tag) { + $widget = $this->treeWidgetFactory->createTagTree($tag); + $this->assignation['tag'] = $tag; + $this->assignation['translation'] = $translation; + $this->assignation['specificTagTree'] = $widget; + } + + return $this->render('@RoadizRozier/tags/tree.html.twig', $this->assignation); + } + + /** + * Return a deletion form for requested tag. + * + * @param Request $request + * @param int $tagId + * + * @return Response + */ + public function deleteAction(Request $request, int $tagId) + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_TAGS_DELETE'); + + /** @var Tag $tag */ + $tag = $this->em()->find(Tag::class, $tagId); + + if ( + $tag !== null && + !$tag->isLocked() + ) { + $this->assignation['tag'] = $tag; + + $form = $this->buildDeleteForm($tag); + $form->handleRequest($request); + + if ( + $form->isSubmitted() && + $form->isValid() && + $form->getData()['tagId'] == $tag->getId() + ) { + /* + * Dispatch event + */ + $this->dispatchEvent(new TagDeletedEvent($tag)); + + $this->em()->remove($tag); + $this->em()->flush(); + + $msg = $this->getTranslator()->trans('tag.%name%.deleted', ['%name%' => $tag->getTranslatedTags()->first()->getName()]); + $this->publishConfirmMessage($request, $msg); + + /* + * Force redirect to avoid resending form when refreshing page + */ + return $this->redirectToRoute('tagsHomePage'); + } + + $this->assignation['form'] = $form->createView(); + + return $this->render('@RoadizRozier/tags/delete.html.twig', $this->assignation); + } + + throw new ResourceNotFoundException(); + } + + /** + * Handle tag creation pages. + * + * @param Request $request + * @param int $tagId + * @param int|null $translationId + * + * @return Response + */ + public function addChildAction(Request $request, int $tagId, ?int $translationId = null) + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_TAGS'); + + $translation = $this->em()->getRepository(Translation::class)->findDefault(); + + if ($translationId !== null) { + $translation = $this->em()->find(Translation::class, $translationId); + } + $parentTag = $this->em()->find(Tag::class, $tagId); + $tag = new Tag(); + $tag->setParent($parentTag); + + if ( + $translation !== null && + $parentTag !== null + ) { + $form = $this->createForm(TagType::class, $tag); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + try { + /* + * Get latest position to add tags after. + */ + $latestPosition = $this->em() + ->getRepository(Tag::class) + ->findLatestPositionInParent($parentTag); + $tag->setPosition($latestPosition + 1); + + $this->em()->persist($tag); + $this->em()->flush(); + + $translatedTag = new TagTranslation($tag, $translation); + $this->em()->persist($translatedTag); + $this->em()->flush(); + /* + * Dispatch event + */ + $this->dispatchEvent(new TagCreatedEvent($tag)); + + $msg = $this->getTranslator()->trans('child.tag.%name%.created', ['%name%' => $tag->getTagName()]); + $this->publishConfirmMessage($request, $msg); + + return $this->redirectToRoute( + 'tagsEditPage', + ['tagId' => $tag->getId()] + ); + } catch (EntityAlreadyExistsException $e) { + $form->addError(new FormError($e->getMessage())); + } + } + + $this->assignation['translation'] = $translation; + $this->assignation['form'] = $form->createView(); + $this->assignation['parentTag'] = $parentTag; + + return $this->render('@RoadizRozier/tags/add.html.twig', $this->assignation); + } + + throw new ResourceNotFoundException(); + } + + /** + * Handle tag nodes page. + * + * @param Request $request + * @param int $tagId + * + * @return Response + * @throws RuntimeError + */ + public function editNodesAction(Request $request, int $tagId) + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_TAGS'); + + $tag = $this->em()->find(Tag::class, $tagId); + + if (null !== $tag) { + $translation = $this->em()->getRepository(Translation::class)->findDefault(); + + $this->assignation['tag'] = $tag; + + /* + * Manage get request to filter list + */ + $listManager = $this->createEntityListManager( + Node::class, + [ + 'tags' => $tag, + ] + ); + $listManager->setDisplayingNotPublishedNodes(true); + $listManager->handle(); + + $this->assignation['filters'] = $listManager->getAssignation(); + $this->assignation['nodes'] = $listManager->getEntities(); + $this->assignation['translation'] = $translation; + + return $this->render('@RoadizRozier/tags/nodes.html.twig', $this->assignation); + } + + throw new ResourceNotFoundException(); + } + + /** + * @param Tag $tag + * + * @return FormInterface + */ + private function buildDeleteForm(Tag $tag) + { + $builder = $this->createFormBuilder() + ->add('tagId', HiddenType::class, [ + 'data' => $tag->getId(), + 'constraints' => [ + new NotNull(), + new NotBlank(), + ], + ]); + + return $builder->getForm(); + } + + /** + * @param false|string $referer + * @param array $tagsIds + * + * @return FormInterface + */ + private function buildBulkDeleteForm( + $referer = false, + array $tagsIds = [] + ) { + $builder = $this->formFactory + ->createNamedBuilder('deleteForm') + ->add('tagsIds', HiddenType::class, [ + 'data' => implode(',', $tagsIds), + 'attr' => ['class' => 'tags-id-bulk-tags'], + 'constraints' => [ + new NotNull(), + new NotBlank(), + ], + ]); + + if (false !== $referer && (new UnicodeString($referer))->startsWith('/')) { + $builder->add('referer', HiddenType::class, [ + 'data' => $referer, + ]); + } + + return $builder->getForm(); + } + + /** + * @param array $data + * + * @return string + */ + private function bulkDeleteTags(array $data) + { + if (!empty($data['tagsIds'])) { + $tagsIds = trim($data['tagsIds']); + $tagsIds = explode(',', $tagsIds); + array_filter($tagsIds); + + $tags = $this->em() + ->getRepository(Tag::class) + ->findBy([ + 'id' => $tagsIds, + // Removed locked tags from bulk deletion + 'locked' => false, + ]); + + /** @var Tag $tag */ + foreach ($tags as $tag) { + /** @var TagHandler $handler */ + $handler = $this->handlerFactory->getHandler($tag); + $handler->removeWithChildrenAndAssociations(); + } + + $this->em()->flush(); + + return $this->getTranslator()->trans('tags.bulk.deleted'); + } + + return $this->getTranslator()->trans('wrong.request'); + } + + protected function onPostUpdate(PersistableInterface $entity, Request $request): void + { + if ($entity instanceof TagTranslation) { + $this->em()->flush(); + /* + * Dispatch event + */ + $this->dispatchEvent( + new TagUpdatedEvent($entity->getTag()) + ); + + $msg = $this->getTranslator()->trans('tag.%name%.updated', [ + '%name%' => $entity->getName(), + ]); + $this->publishConfirmMessage($request, $msg); + } + } + + protected function getPostUpdateRedirection(PersistableInterface $entity): ?Response + { + if ($entity instanceof TagTranslation) { + /** @var Translation $translation */ + $translation = $entity->getTranslation(); + return $this->redirectToRoute( + 'tagsEditTranslatedPage', + [ + 'tagId' => $entity->getTag()->getId(), + 'translationId' => $translation->getId() + ] + ); + } + return null; + } +} diff --git a/lib/Rozier/src/Controllers/Tags/TagsUtilsController.php b/lib/Rozier/src/Controllers/Tags/TagsUtilsController.php new file mode 100644 index 00000000..c60cfb7a --- /dev/null +++ b/lib/Rozier/src/Controllers/Tags/TagsUtilsController.php @@ -0,0 +1,93 @@ +serializer = $serializer; + } + + /** + * Export a Tag in a Json file + * + * @param Request $request + * @param int $tagId + * + * @return Response + */ + public function exportAction(Request $request, int $tagId) + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_TAGS'); + + $existingTag = $this->em()->find(Tag::class, $tagId); + + return new JsonResponse( + $this->serializer->serialize( + $existingTag, + 'json', + SerializationContext::create()->setGroups(['tag', 'position']) + ), + JsonResponse::HTTP_OK, + [ + 'Content-Disposition' => sprintf( + 'attachment; filename="%s"', + 'tag-' . $existingTag->getTagName() . '-' . date("YmdHis") . '.json' + ), + ], + true + ); + } + + /** + * Export a Tag in a Json file + * + * @param Request $request + * @param int $tagId + * + * @return Response + */ + public function exportAllAction(Request $request, int $tagId) + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_TAGS'); + + $existingTags = $this->em() + ->getRepository(Tag::class) + ->findBy(["parent" => null]); + + return new JsonResponse( + $this->serializer->serialize( + $existingTags, + 'json', + SerializationContext::create()->setGroups(['tag', 'position']) + ), + JsonResponse::HTTP_OK, + [ + 'Content-Disposition' => sprintf( + 'attachment; filename="%s"', + 'tag-all-' . date("YmdHis") . '.json' + ), + ], + true + ); + } +} diff --git a/lib/Rozier/src/Controllers/TranslationsController.php b/lib/Rozier/src/Controllers/TranslationsController.php new file mode 100644 index 00000000..94aacc18 --- /dev/null +++ b/lib/Rozier/src/Controllers/TranslationsController.php @@ -0,0 +1,202 @@ +handlerFactory = $handlerFactory; + } + + /** + * @param Request $request + * + * @return Response + * @throws RuntimeError + */ + public function indexAction(Request $request): Response + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_TRANSLATIONS'); + + $this->assignation['translations'] = []; + + $listManager = $this->createEntityListManager( + Translation::class + ); + $listManager->setDisplayingNotPublishedNodes(true); + $listManager->handle(); + + $this->assignation['filters'] = $listManager->getAssignation(); + $translations = $listManager->getEntities(); + + /** @var Translation $translation */ + foreach ($translations as $translation) { + // Make default forms + $form = $this->createNamedFormBuilder('default_trans_' . $translation->getId(), $translation)->getForm(); + $form->handleRequest($request); + if ($form->isSubmitted() && $form->isValid()) { + /** @var TranslationHandler $handler */ + $handler = $this->handlerFactory->getHandler($translation); + $handler->makeDefault(); + $msg = $this->getTranslator()->trans('translation.%name%.made_default', ['%name%' => $translation->getName()]); + $this->publishConfirmMessage($request, $msg); + $this->dispatchEvent(new TranslationUpdatedEvent($translation)); + /* + * Force redirect to avoid resending form when refreshing page + */ + return $this->redirectToRoute( + 'translationsHomePage' + ); + } + + $this->assignation['translations'][] = [ + 'translation' => $translation, + 'defaultForm' => $form->createView(), + ]; + } + + return $this->render('@RoadizRozier/translations/list.html.twig', $this->assignation); + } + + /** + * @param Request $request + * @param int $translationId + * + * @return Response + * @throws RuntimeError + */ + public function editAction(Request $request, int $translationId): Response + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_TRANSLATIONS'); + + /** @var Translation|null $translation */ + $translation = $this->em()->find(Translation::class, $translationId); + + if ($translation === null) { + throw new ResourceNotFoundException(); + } + + $this->assignation['translation'] = $translation; + + $form = $this->createForm(TranslationType::class, $translation); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $this->em()->flush(); + $msg = $this->getTranslator()->trans('translation.%name%.updated', ['%name%' => $translation->getName()]); + $this->publishConfirmMessage($request, $msg); + + $this->dispatchEvent(new TranslationUpdatedEvent($translation)); + /* + * Force redirect to avoid resending form when refreshing page + */ + return $this->redirectToRoute( + 'translationsEditPage', + ['translationId' => $translation->getId()] + ); + } + + $this->assignation['form'] = $form->createView(); + + return $this->render('@RoadizRozier/translations/edit.html.twig', $this->assignation); + } + + /** + * @param Request $request + * + * @return Response + * @throws RuntimeError + */ + public function addAction(Request $request): Response + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_TRANSLATIONS'); + + $translation = new Translation(); + $this->assignation['translation'] = $translation; + + $form = $this->createForm(TranslationType::class, $translation); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $this->em()->persist($translation); + $this->em()->flush(); + + $msg = $this->getTranslator()->trans('translation.%name%.created', ['%name%' => $translation->getName()]); + $this->publishConfirmMessage($request, $msg); + + $this->dispatchEvent(new TranslationCreatedEvent($translation)); + /* + * Force redirect to avoid resending form when refreshing page + */ + return $this->redirectToRoute('translationsHomePage'); + } + + $this->assignation['form'] = $form->createView(); + + return $this->render('@RoadizRozier/translations/add.html.twig', $this->assignation); + } + + /** + * @param Request $request + * @param int $translationId + * + * @return Response + * @throws RuntimeError + */ + public function deleteAction(Request $request, int $translationId): Response + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_TRANSLATIONS'); + + /** @var Translation|null $translation */ + $translation = $this->em()->find(Translation::class, $translationId); + + if (null === $translation) { + throw new ResourceNotFoundException(); + } + + $this->assignation['translation'] = $translation; + $form = $this->createForm(FormType::class); + $form->handleRequest($request); + if ($form->isSubmitted() && $form->isValid()) { + if (false === $translation->isDefaultTranslation()) { + $this->em()->remove($translation); + $this->em()->flush(); + $msg = $this->getTranslator()->trans('translation.%name%.deleted', ['%name%' => $translation->getName()]); + $this->publishConfirmMessage($request, $msg); + $this->dispatchEvent(new TranslationDeletedEvent($translation)); + + return $this->redirectToRoute('translationsHomePage'); + } + $form->addError(new FormError($this->getTranslator()->trans( + 'translation.%name%.cannot_delete_default_translation', + ['%name%' => $translation->getName()] + ))); + } + + $this->assignation['form'] = $form->createView(); + + return $this->render('@RoadizRozier/translations/delete.html.twig', $this->assignation); + } +} diff --git a/lib/Rozier/src/Controllers/Users/UsersController.php b/lib/Rozier/src/Controllers/Users/UsersController.php new file mode 100644 index 00000000..62732744 --- /dev/null +++ b/lib/Rozier/src/Controllers/Users/UsersController.php @@ -0,0 +1,242 @@ +denyAccessUnlessGranted('ROLE_ACCESS_USERS'); + + /* + * Manage get request to filter list + */ + $listManager = $this->createEntityListManager( + User::class, + [], + ['username' => 'ASC'] + ); + $listManager->setDisplayingNotPublishedNodes(true); + /* + * Stored in session + */ + $sessionListFilter = new SessionListFilters('user_item_per_page'); + $sessionListFilter->handleItemPerPage($request, $listManager); + $listManager->handle(); + + $this->assignation['filters'] = $listManager->getAssignation(); + $this->assignation['users'] = $listManager->getEntities(); + + return $this->render('@RoadizRozier/users/list.html.twig', $this->assignation); + } + + /** + * @param Request $request + * @param int $userId + * + * @return Response + * @throws RuntimeError + */ + public function editAction(Request $request, int $userId): Response + { + $this->denyAccessUnlessGranted('ROLE_BACKEND_USER'); + + if ( + !( + $this->isGranted('ROLE_ACCESS_USERS') || + ($this->getUser() instanceof User && $this->getUser()->getId() == $userId) + ) + ) { + throw $this->createAccessDeniedException("You don't have access to this page: ROLE_ACCESS_USERS"); + } + $user = $this->em()->find(User::class, $userId); + if ($user === null) { + throw new ResourceNotFoundException(); + } + if (!$this->isGranted(Role::ROLE_SUPERADMIN) && $user->isSuperAdmin()) { + throw $this->createAccessDeniedException("You cannot edit a super admin."); + } + + $form = $this->createForm(UserType::class, $user); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $this->em()->flush(); + $msg = $this->getTranslator()->trans( + 'user.%name%.updated', + ['%name%' => $user->getUsername()] + ); + $this->publishConfirmMessage($request, $msg); + /* + * Force redirect to avoid resending form when refreshing page + */ + return $this->redirectToRoute( + 'usersEditPage', + ['userId' => $user->getId()] + ); + } + + $this->assignation['user'] = $user; + $this->assignation['form'] = $form->createView(); + + return $this->render('@RoadizRozier/users/edit.html.twig', $this->assignation); + } + + /** + * @param Request $request + * @param int $userId + * + * @return Response + * @throws RuntimeError + */ + public function editDetailsAction(Request $request, int $userId): Response + { + $this->denyAccessUnlessGranted('ROLE_BACKEND_USER'); + + if ( + !( + $this->isGranted('ROLE_ACCESS_USERS') || + ($this->getUser() instanceof User && $this->getUser()->getId() === $userId) + ) + ) { + throw $this->createAccessDeniedException("You don't have access to this page: ROLE_ACCESS_USERS"); + } + $user = $this->em()->find(User::class, $userId); + + if ($user === null) { + throw new ResourceNotFoundException(); + } + if (!$this->isGranted(Role::ROLE_SUPERADMIN) && $user->isSuperAdmin()) { + throw $this->createAccessDeniedException("You cannot edit a super admin."); + } + + $form = $this->createForm(UserDetailsType::class, $user); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + /* + * If pictureUrl is empty, use default Gravatar image. + */ + if ($user->getPictureUrl() == '') { + $user->setPictureUrl($user->getGravatarUrl()); + } + + $this->em()->flush(); + + $msg = $this->getTranslator()->trans( + 'user.%name%.updated', + ['%name%' => $user->getUsername()] + ); + $this->publishConfirmMessage($request, $msg); + + /* + * Force redirect to avoid resending form when refreshing page + */ + return $this->redirectToRoute( + 'usersEditDetailsPage', + ['userId' => $user->getId()] + ); + } + + $this->assignation['user'] = $user; + $this->assignation['form'] = $form->createView(); + + return $this->render('@RoadizRozier/users/editDetails.html.twig', $this->assignation); + } + + /** + * @param Request $request + * + * @return Response + * @throws RuntimeError + */ + public function addAction(Request $request): Response + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_USERS'); + + $user = new User(); + $user->sendCreationConfirmationEmail(true); + $this->assignation['user'] = $user; + + $form = $this->createForm(AddUserType::class, $user); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $this->em()->persist($user); + $this->em()->flush(); + + $msg = $this->getTranslator()->trans('user.%name%.created', ['%name%' => $user->getUsername()]); + $this->publishConfirmMessage($request, $msg); + + return $this->redirectToRoute('usersHomePage'); + } + + $this->assignation['form'] = $form->createView(); + + return $this->render('@RoadizRozier/users/add.html.twig', $this->assignation); + } + + /** + * @param Request $request + * @param int $userId + * + * @return Response + * @throws RuntimeError + */ + public function deleteAction(Request $request, int $userId): Response + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_USERS_DELETE'); + $user = $this->em()->find(User::class, (int) $userId); + + if ($user === null) { + throw new ResourceNotFoundException(); + } + + if (!$this->isGranted(Role::ROLE_SUPERADMIN) && $user->isSuperAdmin()) { + throw $this->createAccessDeniedException("You cannot edit a super admin."); + } + + $form = $this->createForm(FormType::class); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $this->em()->remove($user); + $this->em()->flush(); + $msg = $this->getTranslator()->trans( + 'user.%name%.deleted', + ['%name%' => $user->getUsername()] + ); + $this->publishConfirmMessage($request, $msg); + /* + * Force redirect to avoid resending form when refreshing page + */ + return $this->redirectToRoute('usersHomePage'); + } + + $this->assignation['user'] = $user; + $this->assignation['form'] = $form->createView(); + + return $this->render('@RoadizRozier/users/delete.html.twig', $this->assignation); + } +} diff --git a/lib/Rozier/src/Controllers/Users/UsersGroupsController.php b/lib/Rozier/src/Controllers/Users/UsersGroupsController.php new file mode 100644 index 00000000..4c5ba264 --- /dev/null +++ b/lib/Rozier/src/Controllers/Users/UsersGroupsController.php @@ -0,0 +1,161 @@ +denyAccessUnlessGranted('ROLE_ACCESS_USERS'); + + /** @var User|null $user */ + $user = $this->em()->find(User::class, $userId); + + if ($user !== null) { + $this->assignation['user'] = $user; + + $form = $this->buildEditGroupsForm($user); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $data = $form->getData(); + if ($data['userId'] == $user->getId()) { + if (array_key_exists('group', $data) && $data['group'][0] instanceof Group) { + $group = $data['group'][0]; + } elseif (array_key_exists('group', $data) && is_numeric($data['group'])) { + $group = $this->em()->find(Group::class, $data['group']); + } else { + $group = null; + } + + if ($group !== null) { + $user->addGroup($group); + $this->em()->flush(); + + $this->dispatchEvent(new UserJoinedGroupEvent($user, $group)); + + $msg = $this->getTranslator()->trans('user.%user%.group.%group%.linked', [ + '%user%' => $user->getUserName(), + '%group%' => $group->getName(), + ]); + $this->publishConfirmMessage($request, $msg); + + /* + * Force redirect to avoid resending form when refreshing page + */ + return $this->redirectToRoute( + 'usersEditGroupsPage', + ['userId' => $user->getId()] + ); + } + } + } + + $this->assignation['form'] = $form->createView(); + + return $this->render('@RoadizRozier/users/groups.html.twig', $this->assignation); + } + + throw new ResourceNotFoundException(); + } + + public function removeGroupAction(Request $request, int $userId, int $groupId): Response + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_USERS'); + + /** @var User|null $user */ + $user = $this->em()->find(User::class, $userId); + /** @var Group|null $group */ + $group = $this->em()->find(Group::class, $groupId); + + if (!$this->isGranted($group)) { + throw $this->createAccessDeniedException(); + } + + if ($user !== null) { + $this->assignation['user'] = $user; + $this->assignation['group'] = $group; + + $form = $this->createForm(FormType::class); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $user->removeGroup($group); + $this->em()->flush(); + + $this->dispatchEvent(new UserLeavedGroupEvent($user, $group)); + + $msg = $this->getTranslator()->trans('user.%user%.group.%group%.removed', [ + '%user%' => $user->getUserName(), + '%group%' => $group->getName(), + ]); + $this->publishConfirmMessage($request, $msg); + + /* + * Force redirect to avoid resending form when refreshing page + */ + return $this->redirectToRoute( + 'usersEditGroupsPage', + ['userId' => $user->getId()] + ); + } + + $this->assignation['form'] = $form->createView(); + + return $this->render('@RoadizRozier/users/removeGroup.html.twig', $this->assignation); + } + + throw new ResourceNotFoundException(); + } + + /** + * @param User $user + * + * @return FormInterface + */ + private function buildEditGroupsForm(User $user): FormInterface + { + $defaults = [ + 'userId' => $user->getId(), + ]; + $builder = $this->createFormBuilder($defaults) + ->add( + 'userId', + HiddenType::class, + [ + 'data' => $user->getId(), + 'constraints' => [ + new NotNull(), + new NotBlank(), + ], + ] + ) + ->add( + 'group', + GroupsType::class, + [ + 'label' => 'Group' + ] + ) + ; + + return $builder->getForm(); + } +} diff --git a/lib/Rozier/src/Controllers/Users/UsersRolesController.php b/lib/Rozier/src/Controllers/Users/UsersRolesController.php new file mode 100644 index 00000000..04b0a57a --- /dev/null +++ b/lib/Rozier/src/Controllers/Users/UsersRolesController.php @@ -0,0 +1,153 @@ +denyAccessUnlessGranted('ROLE_ACCESS_USERS'); + + /** @var User|null $user */ + $user = $this->em()->find(User::class, $userId); + + if ($user === null) { + throw new ResourceNotFoundException(); + } + + $this->assignation['user'] = $user; + $form = $this->buildEditRolesForm($user); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + /** @var Role|null $role */ + $role = $this->em()->find(Role::class, $form->get('roleId')->getData()); + + if (null !== $role) { + $user->addRoleEntity($role); + $this->em()->flush(); + + $msg = $this->getTranslator()->trans('user.%user%.role.%role%.linked', [ + '%user%' => $user->getUserName(), + '%role%' => $role->getRole(), + ]); + + $this->publishConfirmMessage($request, $msg); + + /* + * Force redirect to avoid resending form when refreshing page + */ + return $this->redirectToRoute( + 'usersEditRolesPage', + ['userId' => $user->getId()] + ); + } + $form->get('roleId')->addError(new FormError('Role not found')); + } + + $this->assignation['form'] = $form->createView(); + + return $this->render('@RoadizRozier/users/roles.html.twig', $this->assignation); + } + + /** + * Return a deletion form for requested role depending on the user. + * + * @param Request $request + * @param int $userId + * @param int $roleId + * + * @return Response + * @throws RuntimeError + */ + public function removeRoleAction(Request $request, int $userId, int $roleId): Response + { + $this->denyAccessUnlessGranted('ROLE_ACCESS_USERS'); + + /** @var User|null $user */ + $user = $this->em()->find(User::class, $userId); + if ($user === null) { + throw new ResourceNotFoundException(); + } + + /** @var Role|null $role */ + $role = $this->em()->find(Role::class, $roleId); + if ($role === null) { + throw new ResourceNotFoundException(); + } + + if (!$this->isGranted($role->getRole())) { + throw $this->createAccessDeniedException(); + } + + $this->assignation['user'] = $user; + $this->assignation['role'] = $role; + + $form = $this->createForm(FormType::class); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $user->removeRoleEntity($role); + $this->em()->flush(); + $msg = $this->getTranslator()->trans( + 'user.%name%.role_removed', + ['%name%' => $role->getRole()] + ); + $this->publishConfirmMessage($request, $msg); + + /* + * Force redirect to avoid resending form when refreshing page + */ + return $this->redirectToRoute( + 'usersEditRolesPage', + ['userId' => $user->getId()] + ); + } + + $this->assignation['form'] = $form->createView(); + + return $this->render('@RoadizRozier/users/removeRole.html.twig', $this->assignation); + } + + /** + * @param User $user + * + * @return FormInterface + */ + private function buildEditRolesForm(User $user): FormInterface + { + $builder = $this->createFormBuilder() + ->add( + 'roleId', + RolesType::class, + [ + 'label' => 'choose.role', + 'roles' => $user->getRolesEntities(), + ] + ) + ; + + return $builder->getForm(); + } +} diff --git a/lib/Rozier/src/Controllers/Users/UsersSecurityController.php b/lib/Rozier/src/Controllers/Users/UsersSecurityController.php new file mode 100644 index 00000000..27c178c4 --- /dev/null +++ b/lib/Rozier/src/Controllers/Users/UsersSecurityController.php @@ -0,0 +1,63 @@ +denyAccessUnlessGranted('ROLE_ACCESS_USERS'); + /** @var User|null $user */ + $user = $this->em()->find(User::class, $userId); + + if ($user === null) { + throw new ResourceNotFoundException(); + } + + $this->assignation['user'] = $user; + $form = $this->createForm(UserSecurityType::class, $user, [ + 'canChroot' => $this->isGranted("ROLE_SUPERADMIN") + ]); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $this->em()->flush(); + $msg = $this->getTranslator()->trans( + 'user.%name%.security.updated', + ['%name%' => $user->getUsername()] + ); + + $this->publishConfirmMessage($request, $msg); + + /* + * Force redirect to avoid resending form when refreshing page + */ + return $this->redirectToRoute( + 'usersSecurityPage', + ['userId' => $user->getId()] + ); + } + + $this->assignation['form'] = $form->createView(); + + return $this->render('@RoadizRozier/users/security.html.twig', $this->assignation); + } +} diff --git a/lib/Rozier/src/Controllers/WebhookController.php b/lib/Rozier/src/Controllers/WebhookController.php new file mode 100644 index 00000000..807ba1bc --- /dev/null +++ b/lib/Rozier/src/Controllers/WebhookController.php @@ -0,0 +1,133 @@ +webhookDispatcher = $webhookDispatcher; + } + + public function triggerAction(Request $request, string $id): Response + { + $this->denyAccessUnlessGranted($this->getRequiredRole()); + + /** @var Webhook|null $item */ + $item = $this->em()->find($this->getEntityClass(), $id); + + if (!($item instanceof PersistableInterface)) { + throw $this->createNotFoundException(); + } + + $this->denyAccessUnlessItemGranted($item); + + $form = $this->createForm(FormType::class); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + try { + $this->webhookDispatcher->dispatch($item); + $this->em()->flush(); + + $msg = $this->getTranslator()->trans( + 'webhook.%item%.will_be_triggered_in.%seconds%', + [ + '%item%' => $this->getEntityName($item), + '%seconds%' => $item->getThrottleSeconds(), + ] + ); + $this->publishConfirmMessage($request, $msg); + + return $this->redirect($this->urlGenerator->generate($this->getDefaultRouteName())); + } catch (TooManyWebhookTriggeredException $e) { + $form->addError(new FormError('webhook.too_many_triggered_in_period', null, [ + '%time%' => $e->getDoNotTriggerBefore()->format('H:i:s') + ], null, $e)); + } + } + + $this->assignation['form'] = $form->createView(); + $this->assignation['item'] = $item; + + return $this->render( + $this->getTemplateFolder() . '/trigger.html.twig', + $this->assignation, + null, + $this->getTemplateNamespace() + ); + } + + protected function supports(PersistableInterface $item): bool + { + return $item instanceof Webhook; + } + + protected function getNamespace(): string + { + return 'webhook'; + } + + protected function createEmptyItem(Request $request): PersistableInterface + { + return new Webhook(); + } + + protected function getTemplateFolder(): string + { + return '@RoadizRozier/admin/webhooks'; + } + + protected function getRequiredRole(): string + { + return 'ROLE_ACCESS_WEBHOOKS'; + } + + protected function getEntityClass(): string + { + return Webhook::class; + } + + protected function getFormType(): string + { + return WebhookType::class; + } + + protected function getDefaultRouteName(): string + { + return 'webhooksHomePage'; + } + + protected function getEditRouteName(): string + { + return 'webhooksEditPage'; + } + + protected function getEntityName(PersistableInterface $item): string + { + if ($item instanceof Webhook) { + return (string) $item; + } + return ''; + } +} diff --git a/lib/Rozier/src/Explorer/ConfigurableExplorerItem.php b/lib/Rozier/src/Explorer/ConfigurableExplorerItem.php new file mode 100644 index 00000000..4fb67bfa --- /dev/null +++ b/lib/Rozier/src/Explorer/ConfigurableExplorerItem.php @@ -0,0 +1,119 @@ +entity = $entity; + $this->configuration = $configuration; + $this->renderer = $renderer; + $this->documentUrlGenerator = $documentUrlGenerator; + $this->urlGenerator = $urlGenerator; + $this->embedFinderFactory = $embedFinderFactory; + } + + /** + * @inheritDoc + */ + public function getId(): int|string + { + return $this->entity->getId() ?? throw new \RuntimeException('Entity must have an ID'); + } + + /** + * @inheritDoc + */ + public function getAlternativeDisplayable(): ?string + { + $alt = $this->configuration['classname']; + if (!empty($this->configuration['alt_displayable'])) { + $alt = call_user_func([$this->entity, $this->configuration['alt_displayable']]); + if ($alt instanceof \DateTimeInterface) { + $alt = $alt->format('c'); + } + } + return (new UnicodeString($alt ?? ''))->truncate(30, '…')->toString(); + } + + /** + * @inheritDoc + */ + public function getDisplayable(): string + { + $displayable = call_user_func([$this->entity, $this->configuration['displayable']]); + if ($displayable instanceof \DateTimeInterface) { + $displayable = $displayable->format('c'); + } + return (new UnicodeString($displayable ?? ''))->truncate(30, '…')->toString(); + } + + /** + * @inheritDoc + */ + public function getOriginal(): PersistableInterface + { + return $this->entity; + } + + protected function getThumbnail(): ?array + { + /** @var DocumentInterface|null $thumbnail */ + $thumbnail = null; + if (!empty($this->configuration['thumbnail'])) { + $thumbnail = call_user_func([$this->entity, $this->configuration['thumbnail']]); + if ($thumbnail instanceof Collection && $thumbnail->count() > 0 && $thumbnail->first() instanceof DocumentInterface) { + $thumbnail = $thumbnail->first(); + } elseif (is_array($thumbnail) && count($thumbnail) > 0 && $thumbnail[0] instanceof DocumentInterface) { + $thumbnail = $thumbnail[0]; + } + } + + if ($thumbnail instanceof DocumentInterface) { + $thumbnailModel = new DocumentModel( + $thumbnail, + $this->renderer, + $this->documentUrlGenerator, + $this->urlGenerator, + $this->embedFinderFactory + ); + $thumbnail = $thumbnailModel->toArray(); + } else { + $thumbnail = null; + } + + return $thumbnail; + } + + protected function getEditItemPath(): ?string + { + return null; + } +} diff --git a/lib/Rozier/src/Explorer/FolderExplorerItem.php b/lib/Rozier/src/Explorer/FolderExplorerItem.php new file mode 100644 index 00000000..10588ae0 --- /dev/null +++ b/lib/Rozier/src/Explorer/FolderExplorerItem.php @@ -0,0 +1,65 @@ +folder = $folder; + $this->urlGenerator = $urlGenerator; + } + + /** + * @inheritDoc + */ + public function getId(): int|string + { + return $this->folder->getId() ?? throw new \RuntimeException('Entity must have an ID'); + } + + /** + * @inheritDoc + */ + public function getAlternativeDisplayable(): ?string + { + /** @var Folder|null $parent */ + $parent = $this->folder->getParent(); + if (null !== $parent) { + return $parent->getTranslatedFolders()->first()->getName(); + } + return ''; + } + + /** + * @inheritDoc + */ + public function getDisplayable(): string + { + return $this->folder->getTranslatedFolders()->first()->getName(); + } + + /** + * @inheritDoc + */ + public function getOriginal(): Folder + { + return $this->folder; + } + + protected function getEditItemPath(): ?string + { + return $this->urlGenerator->generate('foldersEditPage', [ + 'folderId' => $this->folder->getId() + ]); + } +} diff --git a/lib/Rozier/src/Explorer/FoldersProvider.php b/lib/Rozier/src/Explorer/FoldersProvider.php new file mode 100644 index 00000000..6d15045d --- /dev/null +++ b/lib/Rozier/src/Explorer/FoldersProvider.php @@ -0,0 +1,50 @@ + 'ASC']; + } + + /** + * @inheritDoc + */ + public function supports($item): bool + { + if ($item instanceof Folder) { + return true; + } + + return false; + } + + /** + * @inheritDoc + */ + public function toExplorerItem(mixed $item): ?ExplorerItemInterface + { + if ($item instanceof Folder) { + return new FolderExplorerItem($item, $this->urlGenerator); + } + throw new \InvalidArgumentException('Explorer item must be instance of ' . Folder::class); + } +} diff --git a/lib/Rozier/src/Explorer/SettingExplorerItem.php b/lib/Rozier/src/Explorer/SettingExplorerItem.php new file mode 100644 index 00000000..5c0fd190 --- /dev/null +++ b/lib/Rozier/src/Explorer/SettingExplorerItem.php @@ -0,0 +1,63 @@ +setting = $setting; + $this->urlGenerator = $urlGenerator; + } + + /** + * @inheritDoc + */ + public function getId(): int|string + { + return $this->setting->getId() ?? throw new \RuntimeException('Entity must have an ID'); + } + + /** + * @inheritDoc + */ + public function getAlternativeDisplayable(): ?string + { + if (null !== $this->setting->getSettingGroup()) { + return $this->setting->getSettingGroup()->getName(); + } + return ''; + } + + /** + * @inheritDoc + */ + public function getDisplayable(): string + { + return $this->setting->getName(); + } + + /** + * @inheritDoc + */ + public function getOriginal(): Setting + { + return $this->setting; + } + + protected function getEditItemPath(): ?string + { + return $this->urlGenerator->generate('settingsEditPage', [ + 'settingId' => $this->setting->getId() + ]); + } +} diff --git a/lib/Rozier/src/Explorer/SettingsProvider.php b/lib/Rozier/src/Explorer/SettingsProvider.php new file mode 100644 index 00000000..9c195e53 --- /dev/null +++ b/lib/Rozier/src/Explorer/SettingsProvider.php @@ -0,0 +1,50 @@ + 'ASC']; + } + + /** + * @inheritDoc + */ + public function supports($item): bool + { + if ($item instanceof Setting) { + return true; + } + + return false; + } + + /** + * @inheritDoc + */ + public function toExplorerItem(mixed $item): ?ExplorerItemInterface + { + if ($item instanceof Setting) { + return new SettingExplorerItem($item, $this->urlGenerator); + } + throw new \InvalidArgumentException('Explorer item must be instance of ' . Setting::class); + } +} diff --git a/lib/Rozier/src/Explorer/UserExplorerItem.php b/lib/Rozier/src/Explorer/UserExplorerItem.php new file mode 100644 index 00000000..eb63dc4c --- /dev/null +++ b/lib/Rozier/src/Explorer/UserExplorerItem.php @@ -0,0 +1,68 @@ +user = $user; + $this->urlGenerator = $urlGenerator; + } + + /** + * @inheritDoc + */ + public function getId(): int|string + { + return $this->user->getId() ?? throw new \RuntimeException('Entity must have an ID'); + } + + /** + * @inheritDoc + */ + public function getAlternativeDisplayable(): ?string + { + return $this->user->getEmail(); + } + + /** + * @inheritDoc + */ + public function getDisplayable(): string + { + $fullName = trim( + ($this->user->getFirstName() ?? '') . + ' ' . + ($this->user->getLastName() ?? '') + ); + if ($fullName !== '') { + return $fullName; + } + return $this->user->getUsername(); + } + + /** + * @inheritDoc + */ + public function getOriginal(): User + { + return $this->user; + } + + protected function getEditItemPath(): ?string + { + return $this->urlGenerator->generate('usersEditPage', [ + 'userId' => $this->user->getId() + ]); + } +} diff --git a/lib/Rozier/src/Explorer/UsersProvider.php b/lib/Rozier/src/Explorer/UsersProvider.php new file mode 100644 index 00000000..ea5d35fb --- /dev/null +++ b/lib/Rozier/src/Explorer/UsersProvider.php @@ -0,0 +1,50 @@ + 'ASC']; + } + + /** + * @inheritDoc + */ + public function supports($item): bool + { + if ($item instanceof User) { + return true; + } + + return false; + } + + /** + * @inheritDoc + */ + public function toExplorerItem(mixed $item): ?ExplorerItemInterface + { + if ($item instanceof User) { + return new UserExplorerItem($item, $this->urlGenerator); + } + throw new \InvalidArgumentException('Explorer item must be instance of ' . User::class); + } +} diff --git a/lib/Rozier/src/Forms/AddUserType.php b/lib/Rozier/src/Forms/AddUserType.php new file mode 100644 index 00000000..debb57c3 --- /dev/null +++ b/lib/Rozier/src/Forms/AddUserType.php @@ -0,0 +1,32 @@ +add('groups', GroupsType::class, [ + 'label' => 'user.groups', + 'required' => false, + 'multiple' => true, + 'expanded' => true, + ]) + ; + } + + public function getBlockPrefix(): string + { + return 'add_user'; + } +} diff --git a/lib/Rozier/src/Forms/CustomFormFieldType.php b/lib/Rozier/src/Forms/CustomFormFieldType.php new file mode 100644 index 00000000..998ad6f6 --- /dev/null +++ b/lib/Rozier/src/Forms/CustomFormFieldType.php @@ -0,0 +1,86 @@ +add('label', TextType::class, [ + 'label' => 'label', + 'empty_data' => '', + ]) + ->add('description', MarkdownType::class, [ + 'label' => 'description', + 'required' => false, + ]) + ->add('placeholder', TextType::class, [ + 'label' => 'placeholder', + 'required' => false, + 'help' => 'label_for_field_with_empty_data', + ]) + ->add('type', ChoiceType::class, [ + 'label' => 'type', + 'required' => true, + 'choices' => array_flip(CustomFormField::$typeToHuman), + ]) + ->add('required', CheckboxType::class, [ + 'label' => 'required', + 'required' => false, + 'help' => 'make_this_field_mandatory_for_users', + ]) + ->add('expanded', CheckboxType::class, [ + 'label' => 'expanded', + 'help' => 'use_checkboxes_or_radio_buttons_instead_of_select_box', + 'required' => false, + ]) + ->add( + 'defaultValues', + TextType::class, + [ + 'label' => 'defaultValues', + 'required' => false, + 'attr' => [ + 'placeholder' => 'enter_values_comma_separated', + ], + ] + ) + ->add('groupName', TextType::class, [ + 'label' => 'groupName', + 'required' => false, + 'help' => 'use_the_same_group_names_over_fields_to_gather_them_in_tabs', + ]); + } + + public function getBlockPrefix(): string + { + return 'customformfield'; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'label' => false, + 'fieldName' => '', + 'customForm' => null, + 'data_class' => CustomFormField::class, + 'attr' => [ + 'class' => 'uk-form custom-form-field-form', + ], + ]); + } +} diff --git a/lib/Rozier/src/Forms/DataTransformer/TagTransformer.php b/lib/Rozier/src/Forms/DataTransformer/TagTransformer.php new file mode 100644 index 00000000..b43f84c3 --- /dev/null +++ b/lib/Rozier/src/Forms/DataTransformer/TagTransformer.php @@ -0,0 +1,79 @@ +manager = $manager; + } + + /** + * @param Collection|\iterable|null $tags + * @return array|string + */ + public function transform($tags) + { + if (empty($tags)) { + return ''; + } + $ids = []; + /** @var Tag $tag */ + foreach ($tags as $tag) { + $ids[] = $tag->getId(); + } + return $ids; + } + + /** + * @param string|array $tagIds + * @return array + */ + public function reverseTransform($tagIds) + { + if (!$tagIds) { + return []; + } + + if (is_array($tagIds)) { + $ids = $tagIds; + } else { + $ids = explode(',', $tagIds); + } + + $tags = []; + foreach ($ids as $tagId) { + $tag = $this->manager + ->getRepository(Tag::class) + ->find($tagId) + ; + if (null === $tag) { + throw new TransformationFailedException(sprintf( + 'A tag with id "%s" does not exist!', + $tagId + )); + } + + $tags[] = $tag; + } + + return $tags; + } +} diff --git a/lib/Rozier/src/Forms/DocumentEditType.php b/lib/Rozier/src/Forms/DocumentEditType.php new file mode 100644 index 00000000..09bf4c1b --- /dev/null +++ b/lib/Rozier/src/Forms/DocumentEditType.php @@ -0,0 +1,184 @@ +security = $security; + } + + /** + * @inheritDoc + */ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + /** @var Document $document */ + $document = $builder->getData(); + $builder->add('referer', HiddenType::class, [ + 'data' => $options['referer'], + 'mapped' => false, + ]); + + if ($document->isLocal()) { + $builder->add('filename', TextType::class, [ + 'label' => 'filename', + 'empty_data' => '', + 'constraints' => [ + new NotNull(), + new NotBlank(), + new Regex([ + 'pattern' => '/\.[a-z0-9]+$/i', + 'htmlPattern' => ".[a-z0-9]+$", + 'message' => 'value_is_not_a_valid_filename' + ]), + new UniqueFilename([ + 'document' => $document, + ]), + ], + ]) + ->add('mimeType', TextType::class, [ + 'label' => 'document.mimeType', + 'empty_data' => '', + 'required' => true, + 'constraints' => [ + new NotNull(), + new NotBlank(), + ], + ]) + ->add('private', CheckboxType::class, [ + 'label' => 'private', + 'help' => 'document.private.help', + 'required' => false, + ]) + ; + } + + $builder->add('newDocument', FileType::class, [ + 'label' => 'overwrite.document', + 'required' => false, + 'mapped' => false, + 'constraints' => [ + new File() + ], + ]) + ->add('embed', DocumentEmbedType::class, [ + 'label' => 'document.embed', + 'required' => false, + 'inherit_data' => true, + 'document_platforms' => $options['document_platforms'], + ]) + ->add('imageAverageColor', ColorType::class, [ + 'label' => 'document.imageAverageColor', + 'help' => 'document.imageAverageColor.help', + 'required' => false, + ]) + ; + + if ($document->isImage() || $document->isVideo() || $document->isEmbed()) { + $builder->add('imageWidth', IntegerType::class, [ + 'label' => 'document.width', + 'required' => false + ]); + $builder->add('imageHeight', IntegerType::class, [ + 'label' => 'document.height', + 'required' => false + ]); + } + + if ($document->isAudio() || $document->isVideo() || $document->isEmbed()) { + $builder->add('mediaDuration', IntegerType::class, [ + 'label' => 'document.duration', + 'required' => false + ]); + } + + /* + * Display thumbnails only if current Document is original. + */ + if (null === $document->getOriginal()) { + $builder->add('thumbnails', DocumentCollectionType::class, [ + 'label' => 'document.thumbnails', + 'multiple' => true, + 'required' => false + ]); + } + + $builder->add('folders', FolderCollectionType::class, [ + 'label' => 'folders', + 'multiple' => true, + 'required' => false + ]); + + if ($this->security->isGranted('ROLE_ACCESS_DOCUMENTS_CREATION_DATE')) { + $builder->add('createdAt', DateTimeType::class, [ + 'label' => 'createdAt', + 'help' => 'document.createdAt.help', + 'required' => false, + 'attr' => [ + 'class' => 'rz-datetime-field', + ], + 'date_widget' => 'single_text', + 'date_format' => 'yyyy-MM-dd', + 'placeholder' => [ + 'hour' => 'hour', + 'minute' => 'minute', + ], + 'constraints' => [ + new LessThanOrEqual($builder->getData()->getUpdatedAt()), + new LessThanOrEqual('now'), + ] + ]); + } + } + + /** + * @inheritDoc + */ + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => Document::class + ]); + + $resolver->setRequired('referer'); + $resolver->setAllowedTypes('referer', ['null', 'string']); + + $resolver->setRequired('document_platforms'); + $resolver->setAllowedTypes('document_platforms', ['array']); + } + + + /** + * @inheritDoc + */ + public function getBlockPrefix(): string + { + return 'document_edit'; + } +} diff --git a/lib/Rozier/src/Forms/DocumentEmbedType.php b/lib/Rozier/src/Forms/DocumentEmbedType.php new file mode 100644 index 00000000..74073030 --- /dev/null +++ b/lib/Rozier/src/Forms/DocumentEmbedType.php @@ -0,0 +1,62 @@ +add('embedId', TextType::class, [ + 'label' => 'document.embedId', + 'required' => true, + ]) + ->add('embedPlatform', ChoiceType::class, [ + 'label' => 'document.platform', + 'required' => true, + 'choices' => $services, + 'placeholder' => 'document.no_embed_platform' + ]) + ; + if ($options['required'] === false) { + $builder->get('embedId')->setRequired(false); + $builder->get('embedPlatform')->setRequired(false); + } + } + + /** + * @inheritDoc + */ + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefault('required', true); + $resolver->setRequired('document_platforms'); + $resolver->setAllowedTypes('document_platforms', ['array']); + } + + + /** + * @inheritDoc + */ + public function getBlockPrefix(): string + { + return 'document_embed'; + } +} diff --git a/lib/Rozier/src/Forms/DocumentTranslationType.php b/lib/Rozier/src/Forms/DocumentTranslationType.php new file mode 100644 index 00000000..946c5821 --- /dev/null +++ b/lib/Rozier/src/Forms/DocumentTranslationType.php @@ -0,0 +1,49 @@ +add('referer', HiddenType::class, [ + 'data' => $options['referer'], + 'mapped' => false, + ]) + ->add('name', TextType::class, [ + 'label' => 'name', + 'required' => false, + ]) + ->add('description', MarkdownType::class, [ + 'label' => 'description', + 'required' => false, + ]) + ->add('copyright', TextType::class, [ + 'label' => 'copyright', + 'required' => false, + ]); + } + + /** + * @inheritDoc + */ + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => DocumentTranslation::class + ]); + + $resolver->setRequired('referer'); + $resolver->setAllowedTypes('referer', ['null', 'string']); + } +} diff --git a/lib/Rozier/src/Forms/DynamicType.php b/lib/Rozier/src/Forms/DynamicType.php new file mode 100644 index 00000000..3a65e897 --- /dev/null +++ b/lib/Rozier/src/Forms/DynamicType.php @@ -0,0 +1,49 @@ +setDefaults([ + 'required' => false, + 'attr' => [ + 'class' => 'dynamic_textarea', + ], + 'constraints' => [ + new ValidYaml() + ] + ]); + } +} diff --git a/lib/Rozier/src/Forms/FolderCollectionType.php b/lib/Rozier/src/Forms/FolderCollectionType.php new file mode 100644 index 00000000..aa1db351 --- /dev/null +++ b/lib/Rozier/src/Forms/FolderCollectionType.php @@ -0,0 +1,84 @@ +managerRegistry = $managerRegistry; + } + + /** + * @param FormView $view + * @param FormInterface $form + * @param array $options + */ + public function buildView(FormView $view, FormInterface $form, array $options): void + { + parent::buildView($view, $form, $options); + + $view->vars['provider_class'] = FoldersProvider::class; + } + + /** + * {@inheritdoc} + */ + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'required' => false, + 'class' => Folder::class, + 'multiple' => true, + 'property' => 'id', + ]); + } + + /** + * {@inheritdoc} + * + * @param FormBuilderInterface $builder + * @param array $options + */ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder->addModelTransformer(new FolderCollectionTransformer( + $this->managerRegistry->getManager(), + true + )); + } + + /** + * {@inheritdoc} + */ + public function getParent(): ?string + { + return TextType::class; + } + + /** + * {@inheritdoc} + */ + public function getBlockPrefix(): string + { + return 'folders'; + } +} diff --git a/lib/Rozier/src/Forms/FolderTranslationType.php b/lib/Rozier/src/Forms/FolderTranslationType.php new file mode 100644 index 00000000..16b25967 --- /dev/null +++ b/lib/Rozier/src/Forms/FolderTranslationType.php @@ -0,0 +1,40 @@ +add('name', TextType::class, [ + 'label' => 'name', + ]); + } + + public function getBlockPrefix(): string + { + return 'folder_translation'; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'label' => false, + 'data_class' => FolderTranslation::class, + 'attr' => [ + 'class' => 'uk-form folder-form', + ], + ]); + } +} diff --git a/lib/Rozier/src/Forms/FolderType.php b/lib/Rozier/src/Forms/FolderType.php new file mode 100644 index 00000000..83ab4f95 --- /dev/null +++ b/lib/Rozier/src/Forms/FolderType.php @@ -0,0 +1,57 @@ +add('folderName', TextType::class, [ + 'label' => 'folder.name', + 'empty_data' => '', + ]) + ->add('visible', CheckboxType::class, [ + 'label' => 'visible', + 'required' => false, + ]) + ->add('locked', CheckboxType::class, [ + 'label' => 'locked', + 'help' => 'folder.locked.help', + 'required' => false, + ]) + ->add('color', ColorType::class, [ + 'label' => 'folder.color', + 'required' => false, + ]); + } + + public function getBlockPrefix(): string + { + return 'folder'; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'label' => false, + 'name' => '', + 'data_class' => Folder::class, + 'attr' => [ + 'class' => 'uk-form folder-form', + ], + ]); + } +} diff --git a/lib/Rozier/src/Forms/GeoJsonType.php b/lib/Rozier/src/Forms/GeoJsonType.php new file mode 100644 index 00000000..7cefd14c --- /dev/null +++ b/lib/Rozier/src/Forms/GeoJsonType.php @@ -0,0 +1,35 @@ +addModelTransformer(new CallbackTransformer(function (?array $value) { + return null !== $value ? json_encode($value) : ''; + }, function (?string $value) { + return null !== $value ? json_decode($value) : null; + })); + } + + /** + * {@inheritdoc} + */ + public function getBlockPrefix(): string + { + return 'geojson'; + } + + public function getParent(): string + { + return TextareaType::class; + } +} diff --git a/lib/Rozier/src/Forms/GroupType.php b/lib/Rozier/src/Forms/GroupType.php new file mode 100644 index 00000000..51e21a2c --- /dev/null +++ b/lib/Rozier/src/Forms/GroupType.php @@ -0,0 +1,38 @@ +add('name', TextType::class, [ + 'label' => 'group.name', + 'empty_data' => '', + ]); + } + + /** + * @inheritDoc + */ + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => Group::class, + 'attr' => [ + 'class' => 'uk-form group-form', + ], + ]); + } +} diff --git a/lib/Rozier/src/Forms/LoginType.php b/lib/Rozier/src/Forms/LoginType.php new file mode 100644 index 00000000..37b0804e --- /dev/null +++ b/lib/Rozier/src/Forms/LoginType.php @@ -0,0 +1,95 @@ +urlGenerator = $urlGenerator; + $this->requestStack = $requestStack; + } + + /** + * @inheritDoc + */ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder->add('_username', TextType::class, [ + 'label' => 'username', + 'attr' => [ + 'autocomplete' => 'username' + ], + 'constraints' => [ + new NotNull(), + new NotBlank(), + ], + ]) + ->add('_password', PasswordType::class, [ + 'label' => 'password', + 'attr' => [ + 'autocomplete' => 'current-password' + ], + 'constraints' => [ + new NotNull(), + new NotBlank(), + ], + ]) + ->add('_remember_me', CheckboxType::class, [ + 'label' => 'keep_me_logged_in', + 'required' => false, + 'attr' => [ + 'checked' => true + ], + ]); + + if ($this->requestStack->getMasterRequest()->query->has('_home')) { + $builder->add('_target_path', HiddenType::class, [ + 'data' => $this->urlGenerator->generate('adminHomePage') + ]); + } + } + + /** + * @inheritDoc + */ + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setNormalizer('action', function (Options $options) { + return $this->urlGenerator->generate('loginCheckPage'); + }); + } + + /** + * @inheritDoc + */ + public function getBlockPrefix(): string + { + /* + * No prefix for firewall to catch username and password from request. + */ + return ''; + } +} diff --git a/lib/Rozier/src/Forms/MultiTagType.php b/lib/Rozier/src/Forms/MultiTagType.php new file mode 100644 index 00000000..3e27dbe6 --- /dev/null +++ b/lib/Rozier/src/Forms/MultiTagType.php @@ -0,0 +1,36 @@ +add('names', TextareaType::class, [ + 'label' => 'tags.names', + 'empty_data' => '', + 'attr' => [ + 'placeholder' => 'write.every.tags.names.comma.separated', + ], + 'constraints' => [ + new NotNull(), + new NotBlank(), + new UniqueTagName(), + ], + ]); + } + + public function getBlockPrefix(): string + { + return 'multitags'; + } +} diff --git a/lib/Rozier/src/Forms/Node/AddNodeType.php b/lib/Rozier/src/Forms/Node/AddNodeType.php new file mode 100644 index 00000000..336d4dea --- /dev/null +++ b/lib/Rozier/src/Forms/Node/AddNodeType.php @@ -0,0 +1,146 @@ +managerRegistry = $managerRegistry; + } + + /** + * @param FormBuilderInterface $builder + * @param array $options + */ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder->add('title', TextType::class, [ + 'label' => 'title', + 'empty_data' => '', + 'mapped' => false, + 'constraints' => [ + new NotNull(), + new NotBlank(), + new Length([ + 'max' => 255 + ]) + ], + ]); + + if ($options['showNodeType'] === true) { + $builder->add('nodeType', NodeTypesType::class, [ + 'label' => 'nodeType', + 'constraints' => [ + new NotNull(), + new NotBlank(), + ], + ]); + $builder->get('nodeType')->addModelTransformer(new NodeTypeTransformer( + $this->managerRegistry->getManager() + )); + } + + $builder->add('dynamicNodeName', CheckboxType::class, [ + 'label' => 'node.dynamicNodeName', + 'required' => false, + 'help' => 'dynamic_node_name_will_follow_any_title_change_on_default_translation', + ]) + ->add('visible', CheckboxType::class, [ + 'label' => 'visible', + 'required' => false, + ]) + ->add('locked', CheckboxType::class, [ + 'label' => 'locked', + 'required' => false, + ]) + ->add('hideChildren', CheckboxType::class, [ + 'label' => 'hiding-children', + 'required' => false, + ]) + ->add('status', ChoiceType::class, [ + 'label' => 'node.status', + 'required' => true, + 'choices' => [ + Node::getStatusLabel(Node::DRAFT) => Node::DRAFT, + Node::getStatusLabel(Node::PENDING) => Node::PENDING, + Node::getStatusLabel(Node::PUBLISHED) => Node::PUBLISHED, + Node::getStatusLabel(Node::ARCHIVED) => Node::ARCHIVED, + ], + ]); + + $builder->addEventListener(FormEvents::SUBMIT, function (SubmitEvent $event) { + $node = $event->getData(); + $form = $event->getForm(); + + if (!isset($form['title'])) { + throw new \RuntimeException('title is not submitted'); + } + + if (!$node instanceof Node) { + throw new \RuntimeException('Data is not a Node'); + } + + /* + * Already set Node name before data validation stage. + */ + $node->setNodeName($form['title']->getData() ?? ''); + $event->setData($node); + }); + } + + /** + * @return string + */ + public function getBlockPrefix(): string + { + return 'childnode'; + } + + /** + * @param OptionsResolver $resolver + */ + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => Node::class, + 'label' => false, + 'nodeName' => '', + 'showNodeType' => true, + 'attr' => [ + 'class' => 'uk-form childnode-form', + ], + ]); + + $resolver->setAllowedTypes('nodeName', 'string'); + $resolver->setAllowedTypes('showNodeType', 'boolean'); + } +} diff --git a/lib/Rozier/src/Forms/NodeSource/AbstractConfigurableNodeSourceFieldType.php b/lib/Rozier/src/Forms/NodeSource/AbstractConfigurableNodeSourceFieldType.php new file mode 100644 index 00000000..752fe133 --- /dev/null +++ b/lib/Rozier/src/Forms/NodeSource/AbstractConfigurableNodeSourceFieldType.php @@ -0,0 +1,23 @@ +getDefaultValues() ?? ''); + } +} diff --git a/lib/Rozier/src/Forms/NodeSource/AbstractNodeSourceFieldType.php b/lib/Rozier/src/Forms/NodeSource/AbstractNodeSourceFieldType.php new file mode 100644 index 00000000..b8ad2c6d --- /dev/null +++ b/lib/Rozier/src/Forms/NodeSource/AbstractNodeSourceFieldType.php @@ -0,0 +1,67 @@ +managerRegistry = $managerRegistry; + } + + /** + * Pass nodeSource to form twig template. + * + * @param FormView $view + * @param FormInterface $form + * @param array $options + */ + public function buildView(FormView $view, FormInterface $form, array $options): void + { + parent::buildView($view, $form, $options); + + $view->vars['nodeSource'] = $options['nodeSource']; + $view->vars['nodeTypeField'] = $options['nodeTypeField']; + } + + /** + * @inheritDoc + */ + public function configureOptions(OptionsResolver $resolver): void + { + parent::configureOptions($resolver); + + $resolver->setRequired([ + 'nodeSource', + 'nodeTypeField', + ]); + + $resolver->setAllowedTypes('nodeSource', [NodesSources::class]); + $resolver->setAllowedTypes('nodeTypeField', [NodeTypeField::class]); + } + + + /** + * {@inheritdoc} + */ + public function getParent(): ?string + { + return HiddenType::class; + } +} diff --git a/lib/Rozier/src/Forms/NodeSource/NodeSourceBaseType.php b/lib/Rozier/src/Forms/NodeSource/NodeSourceBaseType.php new file mode 100644 index 00000000..1b9e4b54 --- /dev/null +++ b/lib/Rozier/src/Forms/NodeSource/NodeSourceBaseType.php @@ -0,0 +1,82 @@ +add('title', TextType::class, [ + 'label' => 'title', + 'empty_data' => '', + 'required' => false, + 'attr' => [ + 'data-dev-name' => '{{ nodeSource.' . StringHandler::camelCase('title') . ' }}', + 'lang' => strtolower(str_replace('_', '-', $options['translation']->getLocale())), + 'dir' => $options['translation']->isRtl() ? 'rtl' : 'ltr', + ], + 'constraints' => [ + new Length([ + 'max' => 255, + ]) + ] + ]); + + if ($options['publishable'] === true) { + $builder->add('publishedAt', DateTimeType::class, [ + 'label' => 'publishedAt', + 'required' => false, + 'attr' => [ + 'class' => 'rz-datetime-field', + 'data-dev-name' => '{{ nodeSource.' . StringHandler::camelCase('publishedAt') . ' }}', + ], + 'date_widget' => 'single_text', + 'date_format' => 'yyyy-MM-dd', + 'placeholder' => [ + 'hour' => 'hour', + 'minute' => 'minute', + ], + ]); + } + } + + /** + * {@inheritdoc} + */ + public function getBlockPrefix(): string + { + return 'nodesourcebase'; + } + + /** + * @param OptionsResolver $resolver + */ + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'label' => false, + 'inherit_data' => true, + 'publishable' => false, + ]); + + $resolver->setRequired('translation'); + + $resolver->setAllowedTypes('publishable', 'boolean'); + $resolver->setAllowedTypes('translation', Translation::class); + } +} diff --git a/lib/Rozier/src/Forms/NodeSource/NodeSourceCollectionType.php b/lib/Rozier/src/Forms/NodeSource/NodeSourceCollectionType.php new file mode 100644 index 00000000..583d01e6 --- /dev/null +++ b/lib/Rozier/src/Forms/NodeSource/NodeSourceCollectionType.php @@ -0,0 +1,32 @@ +addEventListener(FormEvents::SUBMIT, [$this, 'onSubmit'], 40); + } + + public function onSubmit(FormEvent $event): void + { + $data = $event->getData(); + $event->setData(array_values($data)); + } +} diff --git a/lib/Rozier/src/Forms/NodeSource/NodeSourceCustomFormType.php b/lib/Rozier/src/Forms/NodeSource/NodeSourceCustomFormType.php new file mode 100644 index 00000000..b46f3a90 --- /dev/null +++ b/lib/Rozier/src/Forms/NodeSource/NodeSourceCustomFormType.php @@ -0,0 +1,121 @@ +nodeHandler = $nodeHandler; + } + + /** + * @param FormBuilderInterface $builder + * @param array $options + */ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder->addEventListener( + FormEvents::PRE_SET_DATA, + [$this, 'onPreSetData'] + ) + ->addEventListener( + FormEvents::POST_SUBMIT, + [$this, 'onPostSubmit'] + ) + ; + } + + /** + * @param OptionsResolver $resolver + */ + public function configureOptions(OptionsResolver $resolver): void + { + parent::configureOptions($resolver); + + $resolver->setDefaults([ + 'required' => false, + 'mapped' => false, + 'class' => CustomForm::class, + 'multiple' => true, + 'property' => 'id', + ]); + } + + /** + * {@inheritdoc} + */ + public function getBlockPrefix(): string + { + return 'custom_forms'; + } + + /** + * @param FormEvent $event + */ + public function onPreSetData(FormEvent $event): void + { + /** @var NodesSources $nodeSource */ + $nodeSource = $event->getForm()->getConfig()->getOption('nodeSource'); + + /** @var NodeTypeField $nodeTypeField */ + $nodeTypeField = $event->getForm()->getConfig()->getOption('nodeTypeField'); + + $event->setData($this->managerRegistry + ->getRepository(CustomForm::class) + ->findByNodeAndField($nodeSource->getNode(), $nodeTypeField)); + } + + /** + * @param FormEvent $event + */ + public function onPostSubmit(FormEvent $event): void + { + /** @var NodesSources $nodeSource */ + $nodeSource = $event->getForm()->getConfig()->getOption('nodeSource'); + + /** @var NodeTypeField $nodeTypeField */ + $nodeTypeField = $event->getForm()->getConfig()->getOption('nodeTypeField'); + + $this->nodeHandler->setNode($nodeSource->getNode()); + $this->nodeHandler->cleanCustomFormsFromField($nodeTypeField, false); + + if (is_array($event->getData())) { + $position = 0.0; + foreach ($event->getData() as $customFormId) { + $manager = $this->managerRegistry->getManager(); + /** @var CustomForm|null $tempCForm */ + $tempCForm = $manager->find(CustomForm::class, (int) $customFormId); + + if ($tempCForm !== null) { + $this->nodeHandler->addCustomFormForField($tempCForm, $nodeTypeField, false, $position); + $position++; + } else { + throw new \RuntimeException('Custom form #' . $customFormId . ' was not found during relationship creation.'); + } + } + } + } +} diff --git a/lib/Rozier/src/Forms/NodeSource/NodeSourceDocumentType.php b/lib/Rozier/src/Forms/NodeSource/NodeSourceDocumentType.php new file mode 100644 index 00000000..0278872f --- /dev/null +++ b/lib/Rozier/src/Forms/NodeSource/NodeSourceDocumentType.php @@ -0,0 +1,130 @@ +nodesSourcesHandler = $nodesSourcesHandler; + } + + /** + * @param FormBuilderInterface $builder + * @param array $options + */ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder->addEventListener( + FormEvents::PRE_SET_DATA, + [$this, 'onPreSetData'] + ) + ->addEventListener( + FormEvents::POST_SUBMIT, + [$this, 'onPostSubmit'] + ) + ; + } + + /** + * @param OptionsResolver $resolver + */ + public function configureOptions(OptionsResolver $resolver): void + { + parent::configureOptions($resolver); + + $resolver->setDefaults([ + 'required' => false, + 'mapped' => false, + 'class' => Document::class, + 'multiple' => true, + 'property' => 'id', + ]); + + $resolver->setRequired([ + 'label', + ]); + } + + /** + * {@inheritdoc} + */ + public function getBlockPrefix(): string + { + return 'documents'; + } + + /** + * @param FormEvent $event + */ + public function onPreSetData(FormEvent $event): void + { + /** @var NodesSources $nodeSource */ + $nodeSource = $event->getForm()->getConfig()->getOption('nodeSource'); + /** @var NodeTypeField $nodeTypeField */ + $nodeTypeField = $event->getForm()->getConfig()->getOption('nodeTypeField'); + + $event->setData($this->managerRegistry + ->getRepository(Document::class) + ->findByNodeSourceAndField( + $nodeSource, + $nodeTypeField + )); + } + + /** + * @param FormEvent $event + * + * @throws \Doctrine\ORM\ORMException + * @throws \Doctrine\ORM\OptimisticLockException + * @throws \Doctrine\ORM\TransactionRequiredException + */ + public function onPostSubmit(FormEvent $event): void + { + /** @var NodesSources $nodeSource */ + $nodeSource = $event->getForm()->getConfig()->getOption('nodeSource'); + /** @var NodeTypeField $nodeTypeField */ + $nodeTypeField = $event->getForm()->getConfig()->getOption('nodeTypeField'); + + $this->nodesSourcesHandler->setNodeSource($nodeSource); + $this->nodesSourcesHandler->cleanDocumentsFromField($nodeTypeField, false); + + if (is_array($event->getData())) { + $position = 0.0; + $manager = $this->managerRegistry->getManager(); + foreach ($event->getData() as $documentId) { + /** @var Document|null $tempDoc */ + $tempDoc = $manager->find(Document::class, (int) $documentId); + + if ($tempDoc !== null) { + $this->nodesSourcesHandler->addDocumentForField($tempDoc, $nodeTypeField, false, $position); + $position++; + } else { + throw new \RuntimeException('Document #' . $documentId . ' was not found during relationship creation.'); + } + } + } + } +} diff --git a/lib/Rozier/src/Forms/NodeSource/NodeSourceJoinType.php b/lib/Rozier/src/Forms/NodeSource/NodeSourceJoinType.php new file mode 100644 index 00000000..27b90477 --- /dev/null +++ b/lib/Rozier/src/Forms/NodeSource/NodeSourceJoinType.php @@ -0,0 +1,100 @@ +setDefault('multiple', true); + } + + /** + * @param FormBuilderInterface $builder + * @param array $options + */ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $configuration = $this->getFieldConfiguration($options); + + $builder->addModelTransformer(new JoinDataTransformer( + $options['nodeTypeField'], + $this->managerRegistry, + $configuration['classname'] + )); + } + + /** + * Pass data to form twig template. + * + * @param FormView $view + * @param FormInterface $form + * @param array $options + */ + public function buildView(FormView $view, FormInterface $form, array $options): void + { + parent::buildView($view, $form, $options); + + $configuration = $this->getFieldConfiguration($options); + $displayableData = []; + + $entities = call_user_func([$options['nodeSource'], $options['nodeTypeField']->getGetterName()]); + + if ($entities instanceof \Traversable) { + /** @var PersistableInterface $entity */ + foreach ($entities as $entity) { + if ($entity instanceof Proxy) { + $entity->__load(); + } + $data = [ + 'id' => $entity->getId(), + 'classname' => $configuration['classname'], + ]; + if (is_callable([$entity, $configuration['displayable']])) { + $data['name'] = call_user_func([$entity, $configuration['displayable']]); + } + $displayableData[] = $data; + } + } elseif ($entities instanceof PersistableInterface) { + if ($entities instanceof Proxy) { + $entities->__load(); + } + $data = [ + 'id' => $entities->getId(), + 'classname' => $configuration['classname'], + ]; + if (is_callable([$entities, $configuration['displayable']])) { + $data['name'] = call_user_func([$entities, $configuration['displayable']]); + } + $displayableData[] = $data; + } + + $view->vars['data'] = $displayableData; + } + + /** + * {@inheritdoc} + */ + public function getBlockPrefix(): string + { + return 'join'; + } +} diff --git a/lib/Rozier/src/Forms/NodeSource/NodeSourceNodeType.php b/lib/Rozier/src/Forms/NodeSource/NodeSourceNodeType.php new file mode 100644 index 00000000..472f3dd2 --- /dev/null +++ b/lib/Rozier/src/Forms/NodeSource/NodeSourceNodeType.php @@ -0,0 +1,127 @@ +nodeHandler = $nodeHandler; + } + + /** + * @param FormBuilderInterface $builder + * @param array $options + */ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder->addEventListener( + FormEvents::PRE_SET_DATA, + [$this, 'onPreSetData'] + ) + ->addEventListener( + FormEvents::POST_SUBMIT, + [$this, 'onPostSubmit'] + ) + ; + } + + /** + * @param OptionsResolver $resolver + */ + public function configureOptions(OptionsResolver $resolver): void + { + parent::configureOptions($resolver); + + $resolver->setDefaults([ + 'required' => false, + 'mapped' => false, + 'class' => Node::class, + 'multiple' => true, + 'property' => 'id', + ]); + } + + /** + * {@inheritdoc} + */ + public function getBlockPrefix(): string + { + return 'nodes'; + } + + /** + * @param FormEvent $event + */ + public function onPreSetData(FormEvent $event): void + { + /** @var NodesSources $nodeSource */ + $nodeSource = $event->getForm()->getConfig()->getOption('nodeSource'); + + /** @var NodeTypeField $nodeTypeField */ + $nodeTypeField = $event->getForm()->getConfig()->getOption('nodeTypeField'); + + /** @var NodeRepository $nodeRepo */ + $nodeRepo = $this->managerRegistry + ->getRepository(Node::class) + ->setDisplayingNotPublishedNodes(true); + $event->setData($nodeRepo->findByNodeAndField( + $nodeSource->getNode(), + $nodeTypeField + )); + } + + /** + * @param FormEvent $event + */ + public function onPostSubmit(FormEvent $event): void + { + /** @var NodesSources $nodeSource */ + $nodeSource = $event->getForm()->getConfig()->getOption('nodeSource'); + + /** @var NodeTypeField $nodeTypeField */ + $nodeTypeField = $event->getForm()->getConfig()->getOption('nodeTypeField'); + + $this->nodeHandler->setNode($nodeSource->getNode()); + $this->nodeHandler->cleanNodesFromField($nodeTypeField, false); + + if (is_array($event->getData())) { + $position = 0.0; + $manager = $this->managerRegistry->getManager(); + foreach ($event->getData() as $nodeId) { + /** @var Node|null $tempNode */ + $tempNode = $manager->find(Node::class, (int) $nodeId); + + if ($tempNode !== null) { + $this->nodeHandler->addNodeForField($tempNode, $nodeTypeField, false, $position); + $position++; + } else { + throw new \RuntimeException('Node #' . $nodeId . ' was not found during relationship creation.'); + } + } + } + } +} diff --git a/lib/Rozier/src/Forms/NodeSource/NodeSourceProviderType.php b/lib/Rozier/src/Forms/NodeSource/NodeSourceProviderType.php new file mode 100644 index 00000000..3471cfdb --- /dev/null +++ b/lib/Rozier/src/Forms/NodeSource/NodeSourceProviderType.php @@ -0,0 +1,146 @@ +container = $container; + } + + /** + * @inheritDoc + */ + public function configureOptions(OptionsResolver $resolver): void + { + parent::configureOptions($resolver); + + $resolver->setDefault('multiple', true); + $resolver->setDefault('asMultiple', false); + $resolver->setAllowedTypes('multiple', ['bool']); + $resolver->setAllowedTypes('asMultiple', ['bool']); + $resolver->setNormalizer('asMultiple', function (Options $options) { + /** @var NodeTypeField $nodeTypeField */ + $nodeTypeField = $options['nodeTypeField']; + if ($nodeTypeField->isMultipleProvider()) { + return true; + } + return false; + }); + } + + /** + * @param FormBuilderInterface $builder + * @param array $options + */ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $configuration = $this->getFieldConfiguration($options); + + $builder->addModelTransformer( + new ProviderDataTransformer( + $options['nodeTypeField'], + $this->getProvider($configuration, $options) + ) + ); + } + + protected function getProvider(array $configuration, array $options): ExplorerProviderInterface + { + if ($this->container->has($configuration['classname'])) { + $provider = $this->container->get($configuration['classname']); + } else { + /** @var ExplorerProviderInterface $provider */ + $provider = new $configuration['classname'](); + } + + if ($provider instanceof AbstractExplorerProvider) { + $provider->setContainer($this->container); + } + + return $provider; + } + + /** + * Pass data to form twig template. + * + * @param FormView $view + * @param FormInterface $form + * @param array $options + */ + public function buildView(FormView $view, FormInterface $form, array $options): void + { + parent::buildView($view, $form, $options); + + $configuration = $this->getFieldConfiguration($options); + if (isset($configuration['options'])) { + $providerOptions = $configuration['options']; + } else { + $providerOptions = []; + } + + $provider = $this->getProvider($configuration, $options); + + $displayableData = []; + $ids = call_user_func([$options['nodeSource'], $options['nodeTypeField']->getGetterName()]); + if (!is_array($ids)) { + $entities = $provider->getItemsById([$ids]); + } else { + $entities = $provider->getItemsById($ids); + } + + /** @var AbstractExplorerItem $entity */ + foreach ($entities as $entity) { + $displayableData[] = $entity->toArray(); + } + + $view->vars['data'] = $displayableData; + + if (isset($options['max_length']) && $options['max_length'] > 0) { + $view->vars['attr']['data-max-length'] = $options['max_length']; + } + if (isset($options['min_length']) && $options['min_length'] > 0) { + $view->vars['attr']['data-min-length'] = $options['min_length']; + } + + $view->vars['provider_class'] = $configuration['classname']; + + if (is_array($providerOptions) && count($providerOptions) > 0) { + $view->vars['provider_options'] = []; + foreach ($providerOptions as $providerOption) { + $view->vars['provider_options'][$providerOption['name']] = $providerOption['value']; + } + } + } + + /** + * {@inheritdoc} + */ + public function getBlockPrefix(): string + { + return 'provider'; + } +} diff --git a/lib/Rozier/src/Forms/NodeSource/NodeSourceSeoType.php b/lib/Rozier/src/Forms/NodeSource/NodeSourceSeoType.php new file mode 100644 index 00000000..c102d487 --- /dev/null +++ b/lib/Rozier/src/Forms/NodeSource/NodeSourceSeoType.php @@ -0,0 +1,63 @@ +add('metaTitle', TextType::class, [ + 'label' => 'metaTitle', + 'help' => 'nodeSource.metaTitle.help', + 'required' => false, + 'attr' => [ + 'data-max-length' => 80, + ], + 'constraints' => [ + new Length([ + 'max' => 80 + ]) + ] + ]) + ->add('metaDescription', TextareaType::class, [ + 'label' => 'metaDescription', + 'help' => 'nodeSource.metaDescription.help', + 'required' => false, + ]) + ->add('noIndex', CheckboxType::class, [ + 'label' => 'nodeSource.noIndex', + 'help' => 'nodeSource.noIndex.help', + 'required' => false, + ]) + ; + } + + /** + * @param OptionsResolver $resolver + */ + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'class' => NodesSources::class, + 'property' => 'id', + ]); + } +} diff --git a/lib/Rozier/src/Forms/NodeSource/NodeSourceType.php b/lib/Rozier/src/Forms/NodeSource/NodeSourceType.php new file mode 100644 index 00000000..719ff0c4 --- /dev/null +++ b/lib/Rozier/src/Forms/NodeSource/NodeSourceType.php @@ -0,0 +1,484 @@ +managerRegistry = $managerRegistry; + } + + /** + * @param FormBuilderInterface $builder + * @param array $options + */ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $fields = $this->getFieldsForSource($builder->getData(), $options['nodeType']); + + if ($options['withTitle'] === true) { + $builder->add('base', NodeSourceBaseType::class, [ + 'publishable' => $options['nodeType']->isPublishable(), + 'translation' => $builder->getData()->getTranslation(), + ]); + } + /** @var NodeTypeField $field */ + foreach ($fields as $field) { + if ($options['withVirtual'] === true || !$field->isVirtual()) { + $builder->add( + $field->getVarName(), + self::getFormTypeFromFieldType($field), + $this->getFormOptionsFromFieldType($builder->getData(), $field, $options) + ); + } + } + } + + /** + * @param OptionsResolver $resolver + */ + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'property' => 'id', + 'withTitle' => true, + 'withVirtual' => true, + ]); + $resolver->setRequired([ + 'class', + 'nodeType', + ]); + $resolver->setAllowedTypes('withTitle', 'boolean'); + $resolver->setAllowedTypes('withVirtual', 'boolean'); + $resolver->setAllowedTypes('nodeType', NodeType::class); + $resolver->setAllowedTypes('class', 'string'); + } + + /** + * {@inheritdoc} + */ + public function getBlockPrefix(): string + { + return 'source'; + } + + /** + * @param NodesSources $source + * @param NodeType $nodeType + * @return array + */ + private function getFieldsForSource(NodesSources $source, NodeType $nodeType): array + { + $criteria = [ + 'nodeType' => $nodeType, + 'visible' => true, + ]; + + $position = [ + 'position' => 'ASC', + ]; + + if (!$this->needsUniversalFields($source)) { + $criteria = array_merge($criteria, ['universal' => false]); + } + + return $this->managerRegistry->getRepository(NodeTypeField::class)->findBy($criteria, $position); + } + + /** + * @param NodesSources $source + * @return bool + */ + private function needsUniversalFields(NodesSources $source): bool + { + return ($source->getTranslation()->isDefaultTranslation() || !$this->hasDefaultTranslation($source)); + } + + /** + * @param NodesSources $source + * @return bool + */ + private function hasDefaultTranslation(NodesSources $source): bool + { + /** @var Translation $defaultTranslation */ + $defaultTranslation = $this->managerRegistry->getRepository(Translation::class) + ->findDefault(); + + $sourceCount = $this->managerRegistry->getRepository(NodesSources::class) + ->setDisplayingAllNodesStatuses(true) + ->setDisplayingNotPublishedNodes(true) + ->countBy([ + 'node' => $source->getNode(), + 'translation' => $defaultTranslation, + ]); + + return $sourceCount === 1; + } + + /** + * Returns a Symfony Form type according to a node-type field. + * + * @param AbstractField $field + * @return class-string + */ + public static function getFormTypeFromFieldType(AbstractField $field): string + { + switch ($field->getType()) { + case AbstractField::COLOUR_T: + return ColorType::class; + + case AbstractField::GEOTAG_T: + case AbstractField::MULTI_GEOTAG_T: + return GeoJsonType::class; + + case AbstractField::STRING_T: + return TextType::class; + + case AbstractField::DATETIME_T: + return DateTimeType::class; + + case AbstractField::DATE_T: + return DateType::class; + + case AbstractField::RICHTEXT_T: + case AbstractField::TEXT_T: + return TextareaType::class; + + case AbstractField::MARKDOWN_T: + return MarkdownType::class; + + case AbstractField::BOOLEAN_T: + return CheckboxType::class; + + case AbstractField::INTEGER_T: + return IntegerType::class; + + case AbstractField::DECIMAL_T: + return NumberType::class; + + case AbstractField::EMAIL_T: + return EmailType::class; + + case AbstractField::RADIO_GROUP_T: + case AbstractField::ENUM_T: + return EnumerationType::class; + + case AbstractField::MULTIPLE_T: + case AbstractField::CHECK_GROUP_T: + return MultipleEnumerationType::class; + + case AbstractField::DOCUMENTS_T: + return NodeSourceDocumentType::class; + + case AbstractField::NODES_T: + return NodeSourceNodeType::class; + + case AbstractField::CHILDREN_T: + return NodeTreeType::class; + + case AbstractField::CUSTOM_FORMS_T: + return NodeSourceCustomFormType::class; + + case AbstractField::JSON_T: + return JsonType::class; + + case AbstractField::CSS_T: + return CssType::class; + + case AbstractField::COUNTRY_T: + return CountryType::class; + + case AbstractField::YAML_T: + return YamlType::class; + + case AbstractField::PASSWORD_T: + return PasswordType::class; + + case AbstractField::MANY_TO_MANY_T: + case AbstractField::MANY_TO_ONE_T: + return NodeSourceJoinType::class; + + case AbstractField::SINGLE_PROVIDER_T: + case AbstractField::MULTI_PROVIDER_T: + return NodeSourceProviderType::class; + + case AbstractField::COLLECTION_T: + return NodeSourceCollectionType::class; + } + + return TextType::class; + } + + /** + * Returns an option array for creating a Symfony Form + * according to a node-type field. + * + * @param NodesSources $nodeSource + * @param NodeTypeField $field + * @param array $formOptions + * @return array + * @throws \ReflectionException + */ + public function getFormOptionsFromFieldType(NodesSources $nodeSource, NodeTypeField $field, array &$formOptions) + { + $options = $this->getDefaultOptions($nodeSource, $field, $formOptions); + + switch ($field->getType()) { + case AbstractField::ENUM_T: + case AbstractField::MULTIPLE_T: + $options = array_merge_recursive($options, [ + 'nodeTypeField' => $field, + ]); + break; + case AbstractField::MANY_TO_ONE_T: + case AbstractField::MANY_TO_MANY_T: + $options = array_merge_recursive($options, [ + 'attr' => [ + 'data-nodetypefield' => $field->getId(), + ], + ]); + break; + case AbstractField::NODES_T: + $options = array_merge_recursive($options, [ + 'attr' => [ + 'data-nodetypes' => json_encode(explode( + ',', + $field->getDefaultValues() ?? '' + )) + ], + ]); + break; + case AbstractField::DATETIME_T: + $options = array_merge_recursive($options, [ + 'date_widget' => 'single_text', + 'date_format' => 'yyyy-MM-dd', + 'attr' => [ + 'class' => 'rz-datetime-field', + ], + 'placeholder' => [ + 'hour' => 'hour', + 'minute' => 'minute', + ], + ]); + break; + case AbstractField::DATE_T: + $options = array_merge_recursive($options, [ + 'widget' => 'single_text', + 'format' => 'yyyy-MM-dd', + 'attr' => [ + 'class' => 'rz-date-field', + ], + 'placeholder' => '', + ]); + break; + case AbstractField::DECIMAL_T: + case AbstractField::INTEGER_T: + $options = array_merge_recursive($options, [ + 'constraints' => [ + new Type('numeric'), + ], + ]); + break; + case AbstractField::EMAIL_T: + $options = array_merge_recursive($options, [ + 'constraints' => [ + new Email(), + new Length([ + 'max' => 255 + ]) + ], + ]); + break; + case AbstractField::STRING_T: + $options = array_merge_recursive($options, [ + 'constraints' => [ + new Length([ + 'max' => 255 + ]) + ], + ]); + break; + case AbstractField::GEOTAG_T: + $options = array_merge_recursive($options, [ + 'attr' => [ + 'class' => 'rz-geotag-field', + ], + ]); + break; + case AbstractField::MULTI_GEOTAG_T: + $options = array_merge_recursive($options, [ + 'attr' => [ + 'class' => 'rz-multi-geotag-field', + ], + ]); + break; + case AbstractField::MARKDOWN_T: + $additionalOptions = Yaml::parse($field->getDefaultValues() ?? '[]'); + $options = array_merge_recursive($options, [ + 'attr' => [ + 'class' => 'markdown_textarea', + ], + ], $additionalOptions); + break; + case AbstractField::CHILDREN_T: + $options = array_merge_recursive($options, [ + 'nodeSource' => $nodeSource, + 'nodeTypeField' => $field + ]); + break; + case AbstractField::COUNTRY_T: + $options = array_merge_recursive($options, [ + 'expanded' => $field->isExpanded(), + ]); + if ('' !== $field->getPlaceholder()) { + $options['placeholder'] = $field->getPlaceholder(); + } + if ($field->getDefaultValues() !== '') { + $countries = explode(',', $field->getDefaultValues() ?? ''); + $countries = array_map('trim', $countries); + $options = array_merge_recursive($options, [ + 'preferred_choices' => $countries, + ]); + } + break; + case AbstractField::COLLECTION_T: + $configuration = Yaml::parse($field->getDefaultValues() ?? ''); + $collectionOptions = [ + 'allow_add' => true, + 'allow_delete' => true, + 'attr' => [ + 'class' => 'rz-collection-form-type' + ], + 'entry_options' => [ + 'label' => false, + ] + ]; + if (isset($configuration['entry_type'])) { + $reflectionClass = new \ReflectionClass($configuration['entry_type']); + if ($reflectionClass->isSubclassOf(AbstractType::class)) { + $collectionOptions['entry_type'] = $reflectionClass->getName(); + } + } + $options = array_merge_recursive($options, $collectionOptions); + break; + } + + return $options; + } + + /** + * Get common options for your node-type field form components. + * + * @param NodesSources $nodeSource + * @param NodeTypeField $field + * @param array $formOptions + * @return array + */ + public function getDefaultOptions(NodesSources $nodeSource, NodeTypeField $field, array &$formOptions): array + { + $label = $field->getLabel(); + $devName = '{{ nodeSource.' . $field->getVarName() . ' }}'; + $options = [ + 'label' => $label, + 'required' => false, + 'attr' => [ + 'data-field-group' => (null !== $field->getGroupName() && '' != $field->getGroupName()) ? + $field->getGroupName() : + 'default', + 'data-field-group-canonical' => ( + null !== $field->getGroupNameCanonical() && + '' != $field->getGroupNameCanonical() + ) ? $field->getGroupNameCanonical() : 'default', + 'data-dev-name' => $devName, + 'autocomplete' => 'off', + 'lang' => strtolower(str_replace('_', '-', $nodeSource->getTranslation()->getLocale())), + 'dir' => $nodeSource->getTranslation()->isRtl() ? 'rtl' : 'ltr', + ], + ]; + if ($field->isUniversal()) { + $options['attr']['data-universal'] = true; + } + if ('' !== $field->getDescription()) { + $options['help'] = $field->getDescription(); + } + if ('' !== $field->getPlaceholder()) { + $options['attr']['placeholder'] = $field->getPlaceholder(); + } + if ($field->getMinLength() > 0) { + $options['attr']['data-min-length'] = $field->getMinLength(); + } + if ($field->getMaxLength() > 0) { + $options['attr']['data-max-length'] = $field->getMaxLength(); + } + if ( + $field->isVirtual() && + $field->getType() !== AbstractField::MANY_TO_ONE_T && + $field->getType() !== AbstractField::MANY_TO_MANY_T + ) { + $options['mapped'] = false; + } + + if ( + in_array($field->getType(), [ + AbstractField::MANY_TO_ONE_T, + AbstractField::MANY_TO_MANY_T, + AbstractField::DOCUMENTS_T, + AbstractField::NODES_T, + AbstractField::CUSTOM_FORMS_T, + AbstractField::MULTI_PROVIDER_T, + AbstractField::SINGLE_PROVIDER_T, + ]) + ) { + $options['nodeTypeField'] = $field; + $options['nodeSource'] = $nodeSource; + unset($options['attr']['dir']); + } + + if ($field->getType() === AbstractField::CHILDREN_T) { + unset($options['attr']['dir']); + } + + return $options; + } +} diff --git a/lib/Rozier/src/Forms/NodeTagsType.php b/lib/Rozier/src/Forms/NodeTagsType.php new file mode 100644 index 00000000..cecf98ac --- /dev/null +++ b/lib/Rozier/src/Forms/NodeTagsType.php @@ -0,0 +1,63 @@ +managerRegistry = $managerRegistry; + } + + /** + * {@inheritdoc} + * + * @param FormBuilderInterface $builder + * @param array $options + */ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder->add('tags', TagsType::class, [ + 'by_reference' => false + ]); + $builder->get('tags') + ->addModelTransformer(new TagTransformer($this->managerRegistry->getManager())); + } + + /** + * {@inheritdoc} + * + * @param OptionsResolver $resolver + */ + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefault('data_class', Node::class); + } + + /** + * {@inheritdoc} + */ + public function getBlockPrefix(): string + { + return 'node_tags'; + } +} diff --git a/lib/Rozier/src/Forms/NodeTreeType.php b/lib/Rozier/src/Forms/NodeTreeType.php new file mode 100644 index 00000000..b1cbc3d9 --- /dev/null +++ b/lib/Rozier/src/Forms/NodeTreeType.php @@ -0,0 +1,141 @@ +authorizationChecker = $authorizationChecker; + $this->requestStack = $requestStack; + $this->treeWidgetFactory = $treeWidgetFactory; + $this->managerRegistry = $managerRegistry; + } + + /** + * {@inheritdoc} + * + * @param FormView $view + * @param FormInterface $form + * @param array $options + */ + public function finishView(FormView $view, FormInterface $form, array $options): void + { + parent::finishView($view, $form, $options); + + if ($options['nodeTypeField']->getType() !== AbstractField::CHILDREN_T) { + throw new \RuntimeException("Given field is not a NodeTypeField::CHILDREN_T field.", 1); + } + + $view->vars['authorizationChecker'] = $this->authorizationChecker; + /* + * Inject data as plain document entities + */ + $view->vars['request'] = $this->requestStack->getCurrentRequest(); + + /* + * Linked types to create quick add buttons + */ + $defaultValues = explode(',', $options['nodeTypeField']->getDefaultValues() ?? ''); + foreach ($defaultValues as $key => $value) { + $defaultValues[$key] = trim($value); + } + + $nodeTypes = $this->managerRegistry->getRepository(NodeType::class) + ->findBy( + ['name' => $defaultValues], + ['displayName' => 'ASC'] + ); + + $view->vars['linkedTypes'] = $nodeTypes; + + $nodeTree = $this->treeWidgetFactory->createNodeTree( + $options['nodeSource']->getNode(), + $options['nodeSource']->getTranslation() + ); + /* + * If node-type has been used as default values, + * we need to restrict node-tree display too. + */ + if (is_array($nodeTypes) && count($nodeTypes) > 0) { + $nodeTree->setAdditionalCriteria([ + 'nodeType' => $nodeTypes + ]); + } + + $view->vars['nodeTree'] = $nodeTree; + $view->vars['nodeStatuses'] = [ + Node::getStatusLabel(Node::DRAFT) => Node::DRAFT, + Node::getStatusLabel(Node::PENDING) => Node::PENDING, + Node::getStatusLabel(Node::PUBLISHED) => Node::PUBLISHED, + Node::getStatusLabel(Node::ARCHIVED) => Node::ARCHIVED, + Node::getStatusLabel(Node::DELETED) => Node::DELETED, + ]; + } + /** + * {@inheritdoc} + */ + public function getParent(): ?string + { + return HiddenType::class; + } + /** + * {@inheritdoc} + */ + public function getBlockPrefix(): string + { + return 'childrennodes'; + } + + /** + * @inheritDoc + */ + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setRequired([ + 'nodeSource', + 'nodeTypeField', + ]); + + $resolver->setAllowedTypes('nodeSource', [NodesSources::class]); + $resolver->setAllowedTypes('nodeTypeField', [NodeTypeField::class]); + } +} diff --git a/lib/Rozier/src/Forms/NodeType.php b/lib/Rozier/src/Forms/NodeType.php new file mode 100644 index 00000000..d3585452 --- /dev/null +++ b/lib/Rozier/src/Forms/NodeType.php @@ -0,0 +1,86 @@ +add('nodeName', TextType::class, [ + 'label' => 'nodeName', + 'empty_data' => '', + 'help' => 'node.nodeName.help', + 'constraints' => [ + new UniqueNodeName([ + 'currentValue' => $options['nodeName'], + ]), + ] + ]) + ->add('dynamicNodeName', CheckboxType::class, [ + 'label' => 'node.dynamicNodeName', + 'required' => false, + 'help' => 'dynamic_node_name_will_follow_any_title_change_on_default_translation', + ]) + ; + + if (null !== $builder->getData() && $builder->getData()->getNodeType()->isReachable()) { + $builder->add('home', CheckboxType::class, [ + 'label' => 'node.isHome', + 'required' => false, + 'attr' => ['class' => 'rz-boolean-checkbox'], + ]); + } + + $builder->add('childrenOrder', ChoiceType::class, [ + 'label' => 'node.childrenOrder', + 'choices' => Node::$orderingFields, + ]) + ->add('childrenOrderDirection', ChoiceType::class, [ + 'label' => 'node.childrenOrderDirection', + 'choices' => [ + 'ascendant' => 'ASC', + 'descendant' => 'DESC', + ], + ]) + ; + + if (null !== $builder->getData() && $builder->getData()->getNodeType()->isReachable()) { + $builder->add('ttl', IntegerType::class, [ + 'label' => 'node.ttl', + 'help' => 'node_time_to_live_cache_on_front_controller', + ]); + } + } + + public function getBlockPrefix(): string + { + return 'node'; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'compound' => true, + 'label' => false, + 'nodeName' => null, + 'data_class' => Node::class, + 'attr' => [ + 'class' => 'uk-form node-form', + ], + ]); + + $resolver->setAllowedTypes('nodeName', 'string'); + } +} diff --git a/lib/Rozier/src/Forms/NodeTypeFieldSerializationType.php b/lib/Rozier/src/Forms/NodeTypeFieldSerializationType.php new file mode 100644 index 00000000..38079b02 --- /dev/null +++ b/lib/Rozier/src/Forms/NodeTypeFieldSerializationType.php @@ -0,0 +1,88 @@ +add('excludedFromSerialization', CheckboxType::class, [ + 'label' => 'nodeTypeField.excludedFromSerialization', + 'help' => 'exclude_this_field_from_api_serialization', + 'required' => false, + ]) + ->add('serializationMaxDepth', IntegerType::class, [ + 'label' => 'nodeTypeField.serializationMaxDepth', + 'required' => false, + 'attr' => [ + 'placeholder' => 'default_value', + ], + 'constraints' => [ + new GreaterThan([ + 'value' => 0 + ]) + ], + ]) + ->add('serializationExclusionExpression', TextareaType::class, [ + 'label' => 'nodeTypeField.serializationExclusionExpression', + 'required' => false, + 'help' => 'exclude_this_field_from_api_serialization_if_expression_result_is_true', + 'attr' => [ + 'placeholder' => 'enter_symfony_expression_language_with_object_as_var_name', + ] + ]) + ->add('serializationGroups', CollectionType::class, [ + 'label' => 'nodeTypeField.serializationGroups', + 'required' => false, + 'allow_add' => true, + 'allow_delete' => true, + 'attr' => [ + 'class' => 'rz-collection-form-type' + ], + 'entry_options' => [ + 'label' => false, + ], + 'entry_type' => TextType::class + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'label' => 'nodeTypeField.serialization', + 'inherit_data' => true, + ]); + } + + /** + * {@inheritdoc} + */ + public function getBlockPrefix(): string + { + return ''; + } + + /** + * {@inheritdoc} + */ + public function getParent(): ?string + { + return FormType::class; + } +} diff --git a/lib/Rozier/src/Forms/NodeTypeFieldType.php b/lib/Rozier/src/Forms/NodeTypeFieldType.php new file mode 100644 index 00000000..46d15fa1 --- /dev/null +++ b/lib/Rozier/src/Forms/NodeTypeFieldType.php @@ -0,0 +1,122 @@ +add('name', TextType::class, [ + 'label' => 'name', + 'empty_data' => '', + 'help' => 'technical_name_for_database_and_templating', + ]) + ->add('label', TextType::class, [ + 'label' => 'label', + 'help' => 'human_readable_field_name', + 'empty_data' => '', + ]) + ->add('type', ChoiceType::class, [ + 'label' => 'type', + 'required' => true, + 'choices' => array_flip(NodeTypeField::$typeToHuman), + ]) + ->add('description', TextType::class, [ + 'label' => 'description', + 'required' => false, + ]) + ->add('placeholder', TextType::class, [ + 'label' => 'placeholder', + 'required' => false, + 'help' => 'label_for_field_with_empty_data', + ]) + ->add('groupName', TextType::class, [ + 'label' => 'groupName', + 'required' => false, + 'help' => 'use_the_same_group_names_over_fields_to_gather_them_in_tabs', + ]) + ->add('visible', CheckboxType::class, [ + 'label' => 'visible', + 'required' => false, + 'help' => 'disable_field_visibility_if_you_dont_want_it_to_be_editable_from_backoffice', + ]) + ->add('indexed', CheckboxType::class, [ + 'label' => 'indexed', + 'required' => false, + 'help' => 'field_should_be_indexed_if_you_plan_to_query_or_order_by_it', + ]) + ->add('universal', CheckboxType::class, [ + 'label' => 'universal', + 'required' => false, + 'help' => 'universal_fields_will_be_only_editable_from_default_translation', + ]) + ->add('expanded', CheckboxType::class, [ + 'label' => 'expanded', + 'help' => 'use_checkboxes_or_radio_buttons_instead_of_select_box', + 'required' => false, + ]) + ->add('excludeFromSearch', CheckboxType::class, [ + 'label' => 'excludeFromSearch', + 'help' => 'exclude_this_field_from_fulltext_search_engine', + 'required' => false, + ]) + ->add('defaultValues', DynamicType::class, [ + 'label' => 'defaultValues', + 'required' => false, + 'help' => 'for_children_node_and_node_references_enter_node_type_names_comma_separated', + 'attr' => [ + 'placeholder' => 'enter_values_comma_separated', + ], + ]) + ->add('minLength', IntegerType::class, [ + 'label' => 'nodeTypeField.minLength', + 'required' => false, + 'attr' => [ + 'placeholder' => 'no_limit', + ], + ]) + ->add('maxLength', IntegerType::class, [ + 'label' => 'nodeTypeField.maxLength', + 'required' => false, + 'attr' => [ + 'placeholder' => 'no_limit', + ], + ]) + ->add('serialization', NodeTypeFieldSerializationType::class, [ + 'data_class' => NodeTypeField::class, + 'required' => false, + ]); + } + + public function getBlockPrefix(): string + { + return 'nodetypefield'; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'label' => false, + 'fieldName' => '', + 'nodeType' => null, + 'data_class' => NodeTypeField::class, + 'attr' => [ + 'class' => 'uk-form node-type-field-form', + ] + ]); + } +} diff --git a/lib/Rozier/src/Forms/NodeTypeType.php b/lib/Rozier/src/Forms/NodeTypeType.php new file mode 100644 index 00000000..3601c424 --- /dev/null +++ b/lib/Rozier/src/Forms/NodeTypeType.php @@ -0,0 +1,92 @@ +add('name', TextType::class, [ + 'label' => 'name', + 'empty_data' => '', + ]); + } + $builder + ->add('displayName', TextType::class, [ + 'label' => 'nodeType.displayName', + ]) + ->add('description', TextType::class, [ + 'label' => 'description', + 'required' => false, + ]) + ->add('visible', CheckboxType::class, [ + 'label' => 'visible', + 'required' => false, + 'help' => 'this_node_type_will_be_available_for_creating_root_nodes', + ]) + ->add('publishable', CheckboxType::class, [ + 'label' => 'publishable', + 'required' => false, + 'help' => 'enables_published_at_field_for_time_based_publication', + ]) + ->add('reachable', CheckboxType::class, [ + 'label' => 'reachable', + 'required' => false, + 'help' => 'mark_this_typed_nodes_as_reachable_with_an_url', + ]) + ->add('searchable', CheckboxType::class, [ + 'label' => 'nodeType.searchable', + 'required' => false, + 'help' => 'allow_this_types_nodes_title_to_be_indexed_into_search_engine', + ]) + ->add('hidingNodes', CheckboxType::class, [ + 'label' => 'nodeType.hidingNodes', + 'required' => false, + 'help' => 'this_node_type_will_hide_all_children_nodes', + ]) + ->add('hidingNonReachableNodes', CheckboxType::class, [ + 'label' => 'nodeType.hidingNonReachableNodes', + 'required' => false, + 'help' => 'nodeType.hidingNonReachableNodes.help', + ]) + ->add('color', ColorType::class, [ + 'label' => 'nodeType.color', + 'required' => false, + ]) + ->add('defaultTtl', IntegerType::class, [ + 'label' => 'nodeType.defaultTtl', + 'required' => false, + 'help' => 'nodeType_default_ttl_when_creating_nodes', + ]) + ; + } + + public function getBlockPrefix(): string + { + return 'nodetypefield'; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'label' => false, + 'name' => '', + 'data_class' => NodeType::class, + 'attr' => [ + 'class' => 'uk-form node-type-form', + ], + ]); + } +} diff --git a/lib/Rozier/src/Forms/RedirectionType.php b/lib/Rozier/src/Forms/RedirectionType.php new file mode 100644 index 00000000..b0d22a9b --- /dev/null +++ b/lib/Rozier/src/Forms/RedirectionType.php @@ -0,0 +1,61 @@ +add('query', TextType::class, [ + 'label' => (!$options['only_query']) ? 'redirection.query' : false, + 'attr' => [ + 'placeholder' => $options['placeholder'] + ], + 'empty_data' => '', + ]); + if ($options['only_query'] === false) { + $builder->add('redirectUri', TextareaType::class, [ + 'label' => 'redirection.redirect_uri', + 'required' => false, + ]) + ->add('type', ChoiceType::class, [ + 'label' => 'redirection.type', + 'choices' => [ + 'redirection.moved_permanently' => Response::HTTP_MOVED_PERMANENTLY, + 'redirection.moved_temporarily' => Response::HTTP_FOUND, + ] + ]); + } + } + + public function getBlockPrefix(): string + { + return 'redirection'; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => Redirection::class, + 'only_query' => false, + 'placeholder' => null, + 'attr' => [ + 'class' => 'uk-form redirection-form', + ] + ]); + } +} diff --git a/lib/Rozier/src/Forms/RoleType.php b/lib/Rozier/src/Forms/RoleType.php new file mode 100644 index 00000000..5fcebdca --- /dev/null +++ b/lib/Rozier/src/Forms/RoleType.php @@ -0,0 +1,41 @@ +add('name', TextType::class, [ + 'label' => 'name', + 'empty_data' => '', + ]); + } + + /** + * {@inheritdoc} + */ + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefault('data_class', Role::class); + } + + /** + * {@inheritdoc} + */ + public function getBlockPrefix(): string + { + return 'role'; + } +} diff --git a/lib/Rozier/src/Forms/SettingGroupType.php b/lib/Rozier/src/Forms/SettingGroupType.php new file mode 100644 index 00000000..9badaa0c --- /dev/null +++ b/lib/Rozier/src/Forms/SettingGroupType.php @@ -0,0 +1,46 @@ +add( + 'name', + TextType::class, + [ + 'label' => 'name', + 'empty_data' => '', + ] + ) + ->add( + 'inMenu', + CheckboxType::class, + [ + 'label' => 'settingGroup.in.menu', + 'required' => false, + ] + ); + } + + /** + * @inheritDoc + */ + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefault('data_class', SettingGroup::class); + } +} diff --git a/lib/Rozier/src/Forms/TagTranslationType.php b/lib/Rozier/src/Forms/TagTranslationType.php new file mode 100644 index 00000000..da47e861 --- /dev/null +++ b/lib/Rozier/src/Forms/TagTranslationType.php @@ -0,0 +1,63 @@ +add('name', TextType::class, [ + 'label' => 'name', + 'empty_data' => '', + 'constraints' => [ + new NotNull(), + new NotBlank(), + // Allow users to rename Tag the same, but tag slug must be different! + new Length([ + 'max' => 255, + ]) + ], + ]) + ->add('description', MarkdownType::class, [ + 'label' => 'description', + 'required' => false, + ]) + ->add('tagTranslationDocuments', TagTranslationDocumentType::class, [ + 'label' => 'documents', + 'required' => false, + 'tagTranslation' => $builder->getForm()->getData(), + ]) + ; + } + + public function getBlockPrefix(): string + { + return 'tag_translation'; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'label' => false, + 'tagName' => '', + 'data_class' => TagTranslation::class, + 'attr' => [ + 'class' => 'uk-form tag-translation-form', + ], + ]); + $resolver->setAllowedTypes('tagName', 'string'); + } +} diff --git a/lib/Rozier/src/Forms/TagType.php b/lib/Rozier/src/Forms/TagType.php new file mode 100644 index 00000000..914520cb --- /dev/null +++ b/lib/Rozier/src/Forms/TagType.php @@ -0,0 +1,75 @@ +add('tagName', TextType::class, [ + 'label' => 'tagName', + 'empty_data' => '', + 'help' => 'tag.tagName.help', + ]) + + ->add('locked', CheckboxType::class, [ + 'label' => 'locked', + 'help' => 'tag.locked.help', + 'required' => false, + ]) + ->add('visible', CheckboxType::class, [ + 'label' => 'visible', + 'required' => false, + ]) + ->add('color', ColorType::class, [ + 'label' => 'tag.color', + 'required' => false, + ]) + ->add('childrenOrder', ChoiceType::class, [ + 'label' => 'tag.childrenOrder', + 'choices' => [ + 'position' => 'position', + 'tagName' => 'tagName', + 'createdAt' => 'createdAt', + 'updatedAt' => 'updatedAt', + ], + ]) + ->add('childrenOrderDirection', ChoiceType::class, [ + 'label' => 'tag.childrenOrderDirection', + 'choices' => [ + 'ascendant' => 'ASC', + 'descendant' => 'DESC', + ], + ]); + } + + public function getBlockPrefix(): string + { + return 'tag'; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'label' => false, + 'tagName' => '', + 'data_class' => Tag::class, + 'attr' => [ + 'class' => 'uk-form tag-form', + ], + ]); + + $resolver->setAllowedTypes('tagName', 'string'); + } +} diff --git a/lib/Rozier/src/Forms/TranslationType.php b/lib/Rozier/src/Forms/TranslationType.php new file mode 100644 index 00000000..b19592dd --- /dev/null +++ b/lib/Rozier/src/Forms/TranslationType.php @@ -0,0 +1,55 @@ +add('name', TextType::class, [ + 'label' => 'name', + 'empty_data' => '', + ]) + ->add('locale', ChoiceType::class, [ + 'label' => 'locale', + 'required' => true, + 'choices' => array_flip(Translation::$availableLocales), + ]) + ->add('available', CheckboxType::class, [ + 'label' => 'available', + 'required' => false, + ]) + ->add('overrideLocale', TextType::class, [ + 'label' => 'overrideLocale', + 'required' => false + ]); + } + + public function getBlockPrefix(): string + { + return 'translation'; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'label' => false, + 'locale' => '', + 'overrideLocale' => '', + 'data_class' => Translation::class, + 'attr' => [ + 'class' => 'uk-form translation-form', + ], + ]); + } +} diff --git a/lib/Rozier/src/Forms/TranstypeType.php b/lib/Rozier/src/Forms/TranstypeType.php new file mode 100644 index 00000000..2ba0eba5 --- /dev/null +++ b/lib/Rozier/src/Forms/TranstypeType.php @@ -0,0 +1,106 @@ +managerRegistry = $managerRegistry; + } + + /** + * @param FormBuilderInterface $builder + * @param array $options + */ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder->add( + 'nodeTypeId', + ChoiceType::class, + [ + 'choices' => $this->getAvailableTypes($options['currentType']), + 'label' => 'nodeType', + 'constraints' => [ + new NotNull(), + new NotBlank(), + ], + ] + ); + } + + /** + * @return string + */ + public function getBlockPrefix(): string + { + return 'transtype'; + } + + /** + * @param OptionsResolver $resolver + */ + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'compound' => true, + 'label' => false, + 'nodeName' => null, + 'attr' => [ + 'class' => 'uk-form transtype-form', + ], + ]); + + $resolver->setRequired([ + 'currentType', + ]); + $resolver->setAllowedTypes('currentType', NodeType::class); + } + + /** + * @param NodeType $currentType + * @return array + */ + protected function getAvailableTypes(NodeType $currentType): array + { + $qb = $this->managerRegistry->getManager()->createQueryBuilder(); + $qb->select('n') + ->from(NodeType::class, 'n') + ->where($qb->expr()->neq('n.id', $currentType->getId())) + ->orderBy('n.displayName', 'ASC'); + + try { + $types = $qb->getQuery()->getResult(); + + $choices = []; + /** @var NodeType $type */ + foreach ($types as $type) { + $choices[$type->getDisplayName()] = $type->getId(); + } + + return $choices; + } catch (NoResultException $e) { + return []; + } + } +} diff --git a/lib/Rozier/src/Forms/UserDetailsType.php b/lib/Rozier/src/Forms/UserDetailsType.php new file mode 100644 index 00000000..b2add740 --- /dev/null +++ b/lib/Rozier/src/Forms/UserDetailsType.php @@ -0,0 +1,94 @@ +add('publicName', TextType::class, [ + 'label' => 'publicName', + 'help' => 'user.publicName.help', + 'required' => false, + ]) + ->add('firstName', TextType::class, [ + 'label' => 'firstName', + 'required' => false + ]) + ->add('lastName', TextType::class, [ + 'label' => 'lastName', + 'required' => false + ]) + ->add('phone', TextType::class, [ + 'label' => 'phone', + 'required' => false + ]) + ->add('facebookName', TextType::class, [ + 'label' => 'facebookName', + 'required' => false, + ]) + ->add('company', TextType::class, [ + 'label' => 'company', + 'required' => false + ]) + ->add('job', TextType::class, [ + 'label' => 'job', + 'required' => false + ]) + ->add('birthday', DateType::class, [ + 'label' => 'birthday', + 'placeholder' => [ + 'year' => 'year', + 'month' => 'month', + 'day' => 'day' + ], + 'required' => false, + 'years' => range(1920, ((int) date('Y')) - 6), + 'widget' => 'single_text', + 'format' => 'yyyy-MM-dd', + 'attr' => [ + 'class' => 'rz-datetime-field', + ], + ]) + ->add('pictureUrl', TextType::class, [ + 'label' => 'pictureUrl', + 'required' => false + ]) + ->add('locale', ChoiceType::class, [ + 'label' => 'user.backoffice.language', + 'required' => false, + 'choices' => RozierApp::$backendLanguages, + 'placeholder' => 'use.website.default_language' + ]) + ; + } + + public function getBlockPrefix(): string + { + return 'user'; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'compound' => true, + 'label' => false, + 'data_class' => User::class, + 'attr' => [ + 'class' => 'uk-form user-form', + ], + ]); + } +} diff --git a/lib/Rozier/src/Forms/UserSecurityType.php b/lib/Rozier/src/Forms/UserSecurityType.php new file mode 100644 index 00000000..dd14825b --- /dev/null +++ b/lib/Rozier/src/Forms/UserSecurityType.php @@ -0,0 +1,91 @@ +add('enabled', CheckboxType::class, [ + 'label' => 'user.enabled', + 'required' => false, + ]) + ->add('locked', CheckboxType::class, [ + 'label' => 'user.locked', + 'required' => false, + ]) + ->add('expiresAt', DateTimeType::class, [ + 'label' => 'user.expiresAt', + 'required' => false, + 'years' => range(date('Y'), ((int) date('Y')) + 2), + 'date_widget' => 'single_text', + 'date_format' => 'yyyy-MM-dd', + 'attr' => [ + 'class' => 'rz-datetime-field', + ], + 'placeholder' => [ + 'hour' => 'hour', + 'minute' => 'minute', + ], + ]) + ->add('expired', CheckboxType::class, [ + 'label' => 'user.force.expired', + 'required' => false, + ]) + ->add('credentialsExpiresAt', DateTimeType::class, [ + 'label' => 'user.credentialsExpiresAt', + 'required' => false, + 'years' => range(date('Y'), ((int) date('Y')) + 2), + 'date_widget' => 'single_text', + 'date_format' => 'yyyy-MM-dd', + 'attr' => [ + 'class' => 'rz-datetime-field', + ], + 'placeholder' => [ + 'hour' => 'hour', + 'minute' => 'minute', + ], + ]) + ->add('credentialsExpired', CheckboxType::class, [ + 'label' => 'user.force.credentialsExpired', + 'required' => false, + ]); + + if ($options['canChroot'] === true) { + $builder->add('chroot', NodesType::class, [ + 'label' => 'chroot', + 'required' => false, + 'multiple' => false, + ]); + } + } + + public function getBlockPrefix(): string + { + return 'user_security'; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'label' => false, + 'data_class' => User::class, + 'canChroot' => false, + 'attr' => [ + 'class' => 'uk-form user-form', + ], + ]); + + $resolver->setAllowedTypes('canChroot', ['bool']); + } +} diff --git a/lib/Rozier/src/Forms/UserType.php b/lib/Rozier/src/Forms/UserType.php new file mode 100644 index 00000000..65bd4b39 --- /dev/null +++ b/lib/Rozier/src/Forms/UserType.php @@ -0,0 +1,54 @@ +add('email', EmailType::class, [ + 'label' => 'email', + 'empty_data' => '', + ]) + ->add('username', TextType::class, [ + 'label' => 'username', + 'empty_data' => '', + ]) + ->add('plainPassword', CreatePasswordType::class, [ + 'invalid_message' => 'password.must.match', + ]) + ; + } + + public function getBlockPrefix(): string + { + return 'user'; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'compound' => true, + 'label' => false, + 'email' => '', + 'username' => '', + 'data_class' => User::class, + 'attr' => [ + 'class' => 'uk-form user-form', + ] + ]); + } +} diff --git a/lib/Rozier/src/Models/CustomFormModel.php b/lib/Rozier/src/Models/CustomFormModel.php new file mode 100644 index 00000000..fe4f00f5 --- /dev/null +++ b/lib/Rozier/src/Models/CustomFormModel.php @@ -0,0 +1,51 @@ +customForm = $customForm; + $this->urlGenerator = $urlGenerator; + $this->translator = $translator; + } + + public function toArray() + { + $countFields = strip_tags($this->translator->trans( + '{0} no.customFormField|{1} 1.customFormField|]1,Inf] %count%.customFormFields', + [ + '%count%' => $this->customForm->getFields()->count() + ] + )); + + return [ + 'id' => $this->customForm->getId(), + 'name' => $this->customForm->getDisplayName(), + 'countFields' => $countFields, + 'color' => $this->customForm->getColor(), + 'customFormsEditPage' => $this->urlGenerator->generate('customFormsEditPage', [ + 'id' => $this->customForm->getId() + ]), + ]; + } +} diff --git a/lib/Rozier/src/Models/DocumentModel.php b/lib/Rozier/src/Models/DocumentModel.php new file mode 100644 index 00000000..237c4376 --- /dev/null +++ b/lib/Rozier/src/Models/DocumentModel.php @@ -0,0 +1,149 @@ + "40x40", + "quality" => 50, + "sharpen" => 5, + "inline" => false, + ]; + public static array $thumbnail80Array = [ + "fit" => "80x80", + "quality" => 50, + "sharpen" => 5, + "inline" => false, + ]; + public static array $previewArray = [ + "width" => 1440, + "quality" => 80, + "inline" => false, + "embed" => true, + ]; + public static array $largeArray = [ + "noProcess" => true, + ]; + + private DocumentInterface $document; + private RendererInterface $renderer; + private DocumentUrlGeneratorInterface $documentUrlGenerator; + private UrlGeneratorInterface $urlGenerator; + private ?EmbedFinderFactory $embedFinderFactory; + + /** + * @param DocumentInterface $document + * @param RendererInterface $renderer + * @param DocumentUrlGeneratorInterface $documentUrlGenerator + * @param UrlGeneratorInterface $urlGenerator + * @param EmbedFinderFactory|null $embedFinderFactory + */ + public function __construct( + DocumentInterface $document, + RendererInterface $renderer, + DocumentUrlGeneratorInterface $documentUrlGenerator, + UrlGeneratorInterface $urlGenerator, + ?EmbedFinderFactory $embedFinderFactory = null + ) { + $this->document = $document; + $this->renderer = $renderer; + $this->documentUrlGenerator = $documentUrlGenerator; + $this->urlGenerator = $urlGenerator; + $this->embedFinderFactory = $embedFinderFactory; + } + + public function toArray(): array + { + $name = (string) $this->document; + $thumbnail80Url = null; + $previewUrl = null; + + if ( + $this->document instanceof Document && + $this->document->getDocumentTranslations()->first() && + $this->document->getDocumentTranslations()->first()->getName() + ) { + $name = $this->document->getDocumentTranslations()->first()->getName(); + } + + $this->documentUrlGenerator->setDocument($this->document); + $hasThumbnail = false; + + if ( + $this->document instanceof HasThumbnailInterface && + $this->document->needsThumbnail() && + $this->document->hasThumbnails() && + false !== $thumbnail = $this->document->getThumbnails()->first() + ) { + $this->documentUrlGenerator->setDocument($thumbnail); + $hasThumbnail = true; + } + + if (!$this->document->isPrivate() && !empty($this->document->getRelativePath())) { + $this->documentUrlGenerator->setOptions(DocumentModel::$thumbnail80Array); + $thumbnail80Url = $this->documentUrlGenerator->getUrl(); + $this->documentUrlGenerator->setOptions(DocumentModel::$previewArray); + $previewUrl = $this->documentUrlGenerator->getUrl(); + } + + if ($this->document instanceof PersistableInterface) { + $id = $this->document->getId(); + $editUrl = $this->urlGenerator + ->generate('documentsEditPage', [ + 'documentId' => $this->document->getId() + ]); + } else { + $id = null; + $editUrl = null; + } + + $embedFinder = $this->embedFinderFactory->createForPlatform( + $this->document->getEmbedPlatform(), + $this->document->getEmbedId() + ); + + return [ + 'id' => $id, + 'filename' => (string) $this->document, + 'name' => $name, + 'hasThumbnail' => $hasThumbnail, + 'isImage' => $this->document->isImage(), + 'isWebp' => $this->document->getMimeType() === 'image/webp', + 'isVideo' => $this->document->isVideo(), + 'isSvg' => $this->document->isSvg(), + 'isEmbed' => $this->document->isEmbed(), + 'isPdf' => $this->document->isPdf(), + 'isPrivate' => $this->document->isPrivate(), + 'shortType' => $this->document->getShortType(), + 'processable' => $this->document->isProcessable(), + 'relativePath' => $this->document->getRelativePath(), + 'editUrl' => $editUrl, + 'preview' => $previewUrl, + 'preview_html' => !$this->document->isPrivate() ? + $this->renderer->render($this->document, DocumentModel::$previewArray) : + null, + 'embedPlatform' => $this->document->getEmbedPlatform(), + 'icon' => null !== $embedFinder + ? $embedFinder->getShortType() + : $this->document->getShortType(), + 'shortMimeType' => $this->document->getShortMimeType(), + 'thumbnail_80' => $thumbnail80Url, + 'url' => $previewUrl ?? $thumbnail80Url ?? null, + ]; + } +} diff --git a/lib/Rozier/src/Models/ModelInterface.php b/lib/Rozier/src/Models/ModelInterface.php new file mode 100644 index 00000000..60726121 --- /dev/null +++ b/lib/Rozier/src/Models/ModelInterface.php @@ -0,0 +1,18 @@ +node = $node; + $this->urlGenerator = $urlGenerator; + } + + public function toArray(): array + { + /** @var NodesSources|false $nodeSource */ + $nodeSource = $this->node->getNodeSources()->first(); + + if (false === $nodeSource) { + return [ + 'id' => $this->node->getId(), + 'title' => $this->node->getNodeName(), + 'nodeName' => $this->node->getNodeName(), + 'isPublished' => $this->node->isPublished(), + 'nodesEditPage' => $this->urlGenerator->generate('nodesEditPage', [ + 'nodeId' => $this->node->getId(), + ]), + 'nodeType' => [ + 'color' => $this->node->getNodeType()->getColor() + ] + ]; + } + + /** @var NodesSourcesDocuments|false $thumbnail */ + $thumbnail = $nodeSource->getDocumentsByFields()->first(); + /** @var Translation $translation */ + $translation = $nodeSource->getTranslation(); + + $result = [ + 'id' => $this->node->getId(), + 'title' => $nodeSource->getTitle() ?? $this->node->getNodeName(), + 'thumbnail' => $thumbnail ? $thumbnail->getDocument() : null, + 'nodeName' => $this->node->getNodeName(), + 'isPublished' => $this->node->isPublished(), + 'nodesEditPage' => $this->urlGenerator->generate('nodesEditSourcePage', [ + 'nodeId' => $this->node->getId(), + 'translationId' => $translation->getId(), + ]), + 'nodeType' => [ + 'color' => $this->node->getNodeType()->getColor() + ] + ]; + + $parent = $this->node->getParent(); + + if ($parent instanceof Node) { + $result['parent'] = [ + 'title' => $parent->getNodeSources()->first()->getTitle() + ]; + $subParent = $parent->getParent(); + if ($subParent instanceof Node) { + $result['subparent'] = [ + 'title' => $subParent->getNodeSources()->first()->getTitle() + ]; + } + } + + return $result; + } +} diff --git a/lib/Rozier/src/Models/NodeSourceModel.php b/lib/Rozier/src/Models/NodeSourceModel.php new file mode 100644 index 00000000..73fef8c1 --- /dev/null +++ b/lib/Rozier/src/Models/NodeSourceModel.php @@ -0,0 +1,70 @@ +nodeSource = $nodeSource; + $this->urlGenerator = $urlGenerator; + } + + public function toArray(): array + { + $node = $this->nodeSource->getNode(); + if (null === $node) { + throw new \RuntimeException('Node-source does not have a Node.'); + } + + /** @var NodesSourcesDocuments|false $thumbnail */ + $thumbnail = $this->nodeSource->getDocumentsByFields()->first(); + /** @var Translation $translation */ + $translation = $this->nodeSource->getTranslation(); + + $result = [ + 'id' => $node->getId(), + 'title' => $this->nodeSource->getTitle(), + 'nodeName' => $node->getNodeName(), + 'thumbnail' => $thumbnail ? $thumbnail->getDocument() : null, + 'isPublished' => $node->isPublished(), + 'nodesEditPage' => $this->urlGenerator->generate('nodesEditSourcePage', [ + 'nodeId' => $node->getId(), + 'translationId' => $translation->getId(), + ]), + 'nodeType' => [ + 'color' => $node->getNodeType()->getColor() + ] + ]; + + $parent = $this->nodeSource->getParent(); + + if ($parent instanceof NodesSources) { + $result['parent'] = [ + 'title' => $parent->getTitle() + ]; + $subparent = $parent->getParent(); + if ($subparent instanceof NodesSources) { + $result['subparent'] = [ + 'title' => $subparent->getTitle() + ]; + } + } + + return $result; + } +} diff --git a/lib/Rozier/src/Models/NodeTypeModel.php b/lib/Rozier/src/Models/NodeTypeModel.php new file mode 100644 index 00000000..bf9e93b0 --- /dev/null +++ b/lib/Rozier/src/Models/NodeTypeModel.php @@ -0,0 +1,33 @@ +nodeType = $nodeType; + } + + public function toArray(): array + { + return [ + 'id' => $this->nodeType->getId(), + 'nodeName' => $this->nodeType->getName(), + 'name' => $this->nodeType->getDisplayName(), + 'color' => $this->nodeType->getColor(), + ]; + } +} diff --git a/lib/Rozier/src/Models/TagModel.php b/lib/Rozier/src/Models/TagModel.php new file mode 100644 index 00000000..10fe026c --- /dev/null +++ b/lib/Rozier/src/Models/TagModel.php @@ -0,0 +1,75 @@ +tag = $tag; + $this->urlGenerator = $urlGenerator; + } + + public function toArray(): array + { + $firstTrans = $this->tag->getTranslatedTags()->first(); + $name = $this->tag->getTagName(); + + if ($firstTrans) { + $name = $firstTrans->getName(); + } + + $result = [ + 'id' => $this->tag->getId(), + 'name' => $name, + 'tagName' => $this->tag->getTagName(), + 'color' => $this->tag->getColor(), + 'parent' => $this->getTagParents($this->tag), + 'editUrl' => $this->urlGenerator->generate('tagsEditPage', [ + 'tagId' => $this->tag->getId() + ]), + ]; + + return $result; + } + + /** + * @param Tag $tag + * @param bool $slash + * @return string + */ + private function getTagParents(Tag $tag, bool $slash = false): string + { + $result = ''; + $parent = $tag->getParent(); + + if ($parent instanceof Tag) { + $superParent = $this->getTagParents($parent, true); + $firstTrans = $parent->getTranslatedTags()->first(); + $name = $parent->getTagName(); + + if ($firstTrans) { + $name = $firstTrans->getName(); + } + + $result = $superParent . $name; + + if ($slash) { + $result .= ' / '; + } + } + + return $result; + } +} diff --git a/lib/Rozier/src/Resources/app/App.js b/lib/Rozier/src/Resources/app/App.js new file mode 100644 index 00000000..c96d1d54 --- /dev/null +++ b/lib/Rozier/src/Resources/app/App.js @@ -0,0 +1,130 @@ +import Vue from 'vue' +import store from './store' +import $ from 'jquery' + +// Services +import KeyboardEventService from './services/KeyboardEventService' +import LoginCheckService from './services/LoginCheckService' + +// Containers +import NodeTypeFieldFormContainer from './containers/NodeTypeFieldFormContainer.vue' +import NodesSearchContainer from './containers/NodesSearchContainer.vue' +import DrawerContainer from './containers/DrawerContainer.vue' +import ExplorerContainer from './containers/ExplorerContainer.vue' +import FilterExplorerContainer from './containers/FilterExplorerContainer.vue' +import TagsEditorContainer from './containers/TagsEditorContainer.vue' +import DocumentPreviewContainer from './containers/DocumentPreviewContainer.vue' +import BlanchetteEditorContainer from './containers/BlanchetteEditorContainer.vue' +import ModalContainer from './containers/ModalContainer.vue' + +// Components +import Overlay from './components/Overlay.vue' + +import { KEYBOARD_EVENT_ESCAPE } from './types/mutationTypes' + +/** + * Root entry for VueJS App. + */ +export default class AppVue { + constructor() { + this.services = [] + this.navTrees = null + this.containers = null + this.documentExplorer = null + this.mainContentComponents = [] + this.registeredContainers = { + NodeTypeFieldFormContainer, + NodesSearchContainer, + DrawerContainer, + ExplorerContainer, + FilterExplorerContainer, + TagsEditorContainer, + DocumentPreviewContainer, + BlanchetteEditorContainer, + ModalContainer, + } + + this.registeredComponents = { + Overlay, + } + + this.vuejsElements = { + ...this.registeredComponents, + ...this.registeredContainers, + } + + this.init() + this.initListeners() + } + + init() { + this.buildNavTrees() + this.buildOtherContainers() + this.buildMainContentComponents() + this.initServices() + } + + initListeners() { + window.addEventListener('pagechange', this.onPageChange.bind(this)) + window.addEventListener('pageload', this.onPageLoaded.bind(this)) + } + + initServices() { + this.services.push(new KeyboardEventService(store)) + this.services.push(new LoginCheckService(store)) + } + + onPageChange() { + store.commit(KEYBOARD_EVENT_ESCAPE) + } + + onPageLoaded(e) { + this.buildMainContentComponents(e.detail) + } + + destroyMainContentComponents() { + this.mainContentComponents.forEach((component) => { + component.$destroy() + }) + } + + buildDocumentExplorer() { + if (document.getElementById('document-explorer')) { + this.documentExplorer = this.buildComponent('#document-explorer') + } + } + + buildOtherContainers() { + if (document.getElementById('vue-containers')) { + this.containers = this.buildComponent('#vue-containers') + } + } + + buildNavTrees() { + if (document.getElementById('main-trees')) { + this.navTrees = this.buildComponent('#main-trees') + } + } + + buildMainContentComponents() { + // Destroy old components + this.destroyMainContentComponents() + + // Looking for new vuejs component + const $vueComponents = $('#main-content').find('[data-vuejs]') + + // Create each component + $vueComponents.each((i, el) => { + this.mainContentComponents.push(this.buildComponent(el)) + }) + } + + buildComponent(el) { + return new Vue({ + delimiters: ['${', '}'], + el: el, + store, + components: this.vuejsElements, + }) + } +} diff --git a/lib/Rozier/src/Resources/app/Lazyload.js b/lib/Rozier/src/Resources/app/Lazyload.js new file mode 100644 index 00000000..0e1f3d36 --- /dev/null +++ b/lib/Rozier/src/Resources/app/Lazyload.js @@ -0,0 +1,530 @@ +import $ from 'jquery' +import { Expo, TweenLite } from 'gsap' +import DocumentsBulk from './components/bulk-edits/DocumentsBulk' +import NodesBulk from './components/bulk-edits/NodesBulk' +import TagsBulk from './components/bulk-edits/TagsBulk' +import DocumentUploader from './components/documents/DocumentUploader' +import NodeTypeFieldsPosition from './components/node-type-fields/NodeTypeFieldsPosition' +import AttributeValuePosition from './components/attribute-values/AttributeValuePosition' +import NodeTypeFieldEdit from './components/node-type-fields/NodeTypeFieldEdit' +import CustomFormFieldsPosition from './components/custom-form-fields/CustomFormFieldsPosition' +import NodeTreeContextActions from './components/trees/NodeTreeContextActions' +import Import from './components/import/Import' +import NodeEditSource from './components/node/NodeEditSource' +import InputLengthWatcher from './widgets/InputLengthWatcher' +import ChildrenNodesField from './widgets/ChildrenNodesField' +import StackNodeTree from './widgets/StackNodeTree' +import SaveButtons from './widgets/SaveButtons' +import TagAutocomplete from './widgets/TagAutocomplete' +import FolderAutocomplete from './widgets/FolderAutocomplete' +import SettingsSaveButtons from './widgets/SettingsSaveButtons' +import NodeTree from './widgets/NodeTree' +import NodeStatuses from './widgets/NodeStatuses' +import YamlEditor from './widgets/YamlEditor' +import MarkdownEditor from './widgets/MarkdownEditor' +import JsonEditor from './widgets/JsonEditor' +import CssEditor from './widgets/CssEditor' +import LeafletGeotagField from './widgets/LeafletGeotagField' +import MultiLeafletGeotagField from './widgets/MultiLeafletGeotagField' +import TagEdit from './components/tag/TagEdit' +import MainTreeTabs from './components/tabs/MainTreeTabs' + +/** + * Lazyload + */ +export default class Lazyload { + constructor() { + this.$linksSelector = null + this.$canvasLoaderContainer = null + this.documentsList = null + this.mainColor = null + this.currentRequest = null + this.nodeTreeContextActions = null + this.documentsBulk = null + this.tagsBulk = null + this.inputLengthWatcher = null + this.documentUploader = null + this.childrenNodesFields = null + this.geotagField = null + this.multiGeotagField = null + this.saveButtons = null + this.tagAutocomplete = null + this.folderAutocomplete = null + this.nodeTypeFieldsPosition = null + this.attributeValuesPosition = null + this.customFormFieldsPosition = null + this.settingsSaveButtons = null + this.nodeTypeFieldEdit = null + this.nodeEditSource = null + this.tagEdit = null + this.markdownEditors = [] + this.jsonEditors = [] + this.cssEditors = [] + this.yamlEditors = [] + this.$window = $(window) + + // Bind methods + this.onPopState = this.onPopState.bind(this) + this.onClick = this.onClick.bind(this) + + this.parseLinks() + + window.removeEventListener('popstate', this.onPopState) + window.addEventListener('popstate', this.onPopState) + + this.$canvasLoaderContainer = $('#canvasloader-container') + this.mainColor = window.Rozier.mainColor ? window.Rozier.mainColor : '#00deab' + this.initLoader() + + /* + * Start history with first hard loaded page + */ + window.history.pushState({}, document.title, window.location.href) + } + + /** + * Init loader + */ + initLoader() { + this.canvasLoader = new window.CanvasLoader('canvasloader-container') + this.canvasLoader.setColor(this.mainColor) + this.canvasLoader.setShape('square') + this.canvasLoader.setDensity(90) + this.canvasLoader.setRange(0.8) + this.canvasLoader.setSpeed(4) + this.canvasLoader.setFPS(30) + } + + parseLinks() { + this.$linksSelector = $("a:not('[target=_blank]')").not('.rz-no-ajax-link').not('[download]').not('[href="#"]') + } + + /** + * Bind links to load pages + * @param {Event} event + */ + onClick(event) { + let $link = $(event.currentTarget) + let href = $link.attr('href') + + if ( + typeof href !== 'undefined' && + !$link.hasClass('rz-no-ajax-link') && + href !== '' && + href !== '#' && + (href.indexOf(window.Rozier.baseUrl) >= 0 || href.charAt(0) === '/' || href.charAt(0) === '?') + ) { + event.preventDefault() + + if (this.clickTimeout) { + clearTimeout(this.clickTimeout) + } + + this.clickTimeout = window.setTimeout(() => { + window.history.pushState({}, null, $link.attr('href')) + this.onPopState(null) + }, 0) + + return false + } + } + + /** + * On pop state + * @param {Event} event + */ + onPopState(event) { + let state = null + + if (event !== null && event.originalEvent) { + state = event.originalEvent.state + } + + if (typeof state === 'undefined' || state === null) { + state = window.history.state + } + + if (state !== null) { + this.canvasLoader.show() + this.loadContent(state, window.location) + } + } + + /** + * Load content (ajax) + * @param {Object} state + * @param {Object} location + */ + loadContent(state, location) { + /* + * Delay loading if user is click like devil + */ + if (this.currentTimeout) { + clearTimeout(this.currentTimeout) + } + + this.currentTimeout = window.setTimeout(() => { + /* + * Trigger event on window to notify open + * widgets to close. + */ + let pageChangeEvent = new CustomEvent('pagechange') + window.dispatchEvent(pageChangeEvent) + + this.currentRequest = $.ajax({ + url: location.href, + type: 'get', + dataType: 'html', + cache: false, + data: state.headerData, + beforeSend: (xhr) => { + xhr.setRequestHeader('X-Partial', true) + }, + }) + .done((data) => { + this.applyContent(data) + this.canvasLoader.hide() + let pageLoadEvent = new CustomEvent('pageload', { detail: data }) + window.dispatchEvent(pageLoadEvent) + }) + .fail((data) => { + if (typeof data.responseText !== 'undefined') { + try { + let exception = JSON.parse(data.responseText) + window.UIkit.notify({ + message: exception.message, + status: 'danger', + timeout: 3000, + pos: 'top-center', + }) + } catch (e) { + // No valid JsonResponse, need to refresh page + window.location.href = location.href + } + } else { + window.UIkit.notify({ + message: window.Rozier.messages.forbiddenPage, + status: 'danger', + timeout: 3000, + pos: 'top-center', + }) + } + + this.canvasLoader.hide() + }) + }, 100) + } + + refreshCodemirrorEditor() { + for (let editor of this.markdownEditors) { + editor.forceEditorUpdate() + } + for (let editor of this.yamlEditors) { + editor.forceEditorUpdate() + } + for (let editor of this.cssEditors) { + editor.forceEditorUpdate() + } + for (let editor of this.jsonEditors) { + editor.forceEditorUpdate() + } + } + + /** + * Apply content to main content. + * + * @param {string} data + * @return {void} + */ + applyContent(data) { + let $container = $('#main-content-scrollable') + let $old = $container.find('.content-global') + + let $tempData = $(data) + /* + * If AJAX request data is an entire HTML page. + */ + /** @var {jQuery} $ajaxRoot */ + const $ajaxRoot = $tempData.find('[data-ajax-root]') + if ($ajaxRoot.length) { + $tempData = $($ajaxRoot.html()) + } + + $tempData.addClass('new-content-global') + // Removed previous ajax meta[title] tags + $container.find('meta[name="title"]').remove() + // Append Ajax loaded data to DOM + $container.append($tempData) + + /** @var {jQuery} $metaTitle */ + const $metaTitle = $container.find('meta[name="title"]') + if ($metaTitle.length) { + document.title = $metaTitle[0].getAttribute('content') + } + + $tempData = $container.find('.new-content-global') + + $old.fadeOut(100, () => { + $old.remove() + this.generalBind() + $tempData.fadeIn(200, () => { + $tempData.removeClass('new-content-global') + let pageShowEndEvent = new CustomEvent('pageshowend') + window.dispatchEvent(pageShowEndEvent) + }) + }) + } + + bindAjaxLink() { + this.parseLinks() + this.$linksSelector.off('click', this.onClick) + this.$linksSelector.on('click', this.onClick) + } + + /** + * General bind on page load + * @return {[type]} [description] + */ + generalBind() { + this.generalUnbind([ + this.documentsBulk, + this.mainTreeTabs, + this.nodesBulk, + this.tagsBulk, + this.inputLengthWatcher, + this.documentUploader, + this.childrenNodesFields, + this.geotagField, + this.multiGeotagField, + this.stackNodeTrees, + this.nodeTreeContextActions, + this.tagAutocomplete, + this.folderAutocomplete, + this.nodeTypeFieldsPosition, + this.attributeValuesPosition, + this.customFormFieldsPosition, + this.settingsSaveButtons, + this.nodeTypeFieldEdit, + this.nodeEditSource, + this.tagEdit, + this.nodeTree, + ]) + this.bindAjaxLink() + this.markdownEditors = [] + this.jsonEditors = [] + this.cssEditors = [] + this.yamlEditors = [] + + this.documentsBulk = new DocumentsBulk() + this.mainTreeTabs = new MainTreeTabs() + this.nodesBulk = new NodesBulk() + this.tagsBulk = new TagsBulk() + this.inputLengthWatcher = new InputLengthWatcher() + this.documentUploader = new DocumentUploader(window.Rozier.messages.dropzone) + this.childrenNodesFields = new ChildrenNodesField() + this.geotagField = new LeafletGeotagField() + this.multiGeotagField = new MultiLeafletGeotagField() + + this.stackNodeTrees = new StackNodeTree() + + if (this.saveButtons) { + this.saveButtons.unbind() + } + this.saveButtons = new SaveButtons() + + this.tagAutocomplete = new TagAutocomplete() + this.folderAutocomplete = new FolderAutocomplete() + this.nodeTypeFieldsPosition = new NodeTypeFieldsPosition() + this.attributeValuesPosition = new AttributeValuePosition() + this.customFormFieldsPosition = new CustomFormFieldsPosition() + this.nodeTreeContextActions = new NodeTreeContextActions() + this.settingsSaveButtons = new SettingsSaveButtons() + this.nodeTypeFieldEdit = new NodeTypeFieldEdit() + this.nodeEditSource = new NodeEditSource() + this.tagEdit = new TagEdit() + this.nodeTree = new NodeTree() + + // Codemirror + this.initMarkdownEditors() + this.initJsonEditors() + this.initCssEditors() + this.initYamlEditors() + this.initFilterBars() + this.initColorPickers() + this.initCollectionsForms() + + // Animate actions menu + if ($('.actions-menu').length) { + TweenLite.to('.actions-menu', 0.5, { right: 0, delay: 0.4, ease: Expo.easeOut }) + } + + window.Rozier.initNestables() + window.Rozier.bindMainTrees() + window.Rozier.nodeStatuses = new NodeStatuses() + + // Switch checkboxes + this.initBootstrapSwitches() + + window.Rozier.getMessages() + + if (typeof window.Rozier.importRoutes !== 'undefined' && window.Rozier.importRoutes !== null) { + window.Rozier.import = new Import(window.Rozier.importRoutes) + window.Rozier.importRoutes = null + } + } + + generalUnbind(objects) { + for (let object of objects) { + if (object) { + object.unbind() + } + } + } + + initCollectionsForms($scope = null) { + const _this = this + let $types = null + if ($scope !== null) { + $types = $scope.find('.rz-collection-form-type') + } else { + $types = $('.rz-collection-form-type') + } + if ($types.length) { + $types.collection({ + up: '', + down: '', + add: '', + remove: '', + after_add: (collection, element) => { + _this.initMarkdownEditors(element) + _this.initJsonEditors(element) + _this.initCssEditors(element) + _this.initYamlEditors(element) + _this.initBootstrapSwitches(element) + _this.initColorPickers(element) + _this.initCollectionsForms(element) + + let $vueComponents = element.find('[data-vuejs]') + // Create each component + $vueComponents.each((i, el) => { + window.Rozier.vueApp.mainContentComponents.push(window.Rozier.vueApp.buildComponent(el)) + }) + return true + }, + }) + } + } + + initColorPickers($scope) { + let $colorPickerInput = $('.colorpicker-input') + + if ($scope && $scope.length) { + $colorPickerInput = $scope.find('.colorpicker-input') + } + + // Init colorpicker + if ($colorPickerInput.length) { + $colorPickerInput.minicolors() + } + } + + initBootstrapSwitches($scope) { + let $checkboxes = $('.rz-boolean-checkbox') + if ($scope && $scope.length) { + $checkboxes = $scope.find('.rz-boolean-checkbox') + } + + // Switch checkboxes + $checkboxes.bootstrapSwitch({ + size: 'small', + }) + } + + initMarkdownEditors($scope) { + // Init markdown-preview + let $textareasMarkdown = [] + if ($scope && $scope.length) { + $textareasMarkdown = $scope.find('textarea[data-rz-markdowneditor]') + } else { + $textareasMarkdown = $('textarea[data-rz-markdowneditor]') + } + let editorCount = $textareasMarkdown.length + + if (editorCount) { + for (let i = 0; i < editorCount; i++) { + this.markdownEditors.push(new MarkdownEditor($textareasMarkdown.eq(i), i)) + } + } + } + + initJsonEditors($scope) { + // Init json-preview + let $textareasJson = [] + if ($scope && $scope.length) { + $textareasJson = $scope.find('textarea[data-rz-jsoneditor]') + } else { + $textareasJson = $('textarea[data-rz-jsoneditor]') + } + let editorCount = $textareasJson.length + if (editorCount) { + for (let i = 0; i < editorCount; i++) { + this.jsonEditors.push(new JsonEditor($textareasJson.eq(i), i)) + } + } + } + + initCssEditors($scope) { + // Init css-preview + let $textareasCss = [] + if ($scope && $scope.length) { + $textareasCss = $scope.find('textarea[data-rz-csseditor]') + } else { + $textareasCss = $('textarea[data-rz-csseditor]') + } + let editorCount = $textareasCss.length + + if (editorCount) { + for (let i = 0; i < editorCount; i++) { + this.cssEditors.push(new CssEditor($textareasCss.eq(i), i)) + } + } + } + + initYamlEditors($scope) { + // Init yaml-preview + let $textareasYaml = [] + if ($scope && $scope.length) { + $textareasYaml = $scope.find('textarea[data-rz-yamleditor]') + } else { + $textareasYaml = $('textarea[data-rz-yamleditor]') + } + let editorCount = $textareasYaml.length + + if (editorCount) { + for (let i = 0; i < editorCount; i++) { + this.yamlEditors.push(new YamlEditor($textareasYaml.eq(i), i)) + } + } + } + + initFilterBars() { + const $selectItemPerPage = $('select.item-per-page') + + if ($selectItemPerPage.length) { + $selectItemPerPage.off('change') + $selectItemPerPage.on('change', (event) => { + $(event.currentTarget).parents('form').submit() + }) + } + } + + /** + * Resize + */ + resize() { + if (this.$canvasLoaderContainer.length) { + this.$canvasLoaderContainer[0].style.left = + window.Rozier.mainContentScrollableOffsetLeft + window.Rozier.mainContentScrollableWidth / 2 + 'px' + } + } +} diff --git a/lib/Rozier/src/Resources/app/Rozier.js b/lib/Rozier/src/Resources/app/Rozier.js new file mode 100644 index 00000000..485fc3d3 --- /dev/null +++ b/lib/Rozier/src/Resources/app/Rozier.js @@ -0,0 +1,879 @@ +import $ from 'jquery' +import Lazyload from './Lazyload' +import EntriesPanel from './components/panels/EntriesPanel' +import VueApp from './App' +import { PointerEventsPolyfill } from './utils/plugins' +import { TweenLite, Expo } from 'gsap' +import NodeTreeContextActions from './components/trees/NodeTreeContextActions' +import RozierMobile from './RozierMobile' + +require('gsap/ScrollToPlugin') +/** + * Rozier root entry + */ +export default class Rozier { + constructor() { + this.$window = null + this.$body = null + + this.windowWidth = null + this.windowHeight = null + this.resizeFirst = true + this.mobile = null + + this.nodeTrees = [] + this.treeTrees = [] + + this.$userPanelContainer = null + this.$minifyTreePanelButton = null + this.$mainTrees = null + this.$mainTreesContainer = null + this.$mainTreeElementName = null + this.$treeContextualButton = null + this.$nodesSourcesSearch = null + this.nodesSourcesSearchHeight = null + this.$nodeTreeHead = null + this.nodeTreeHeadHeight = null + this.$treeScrollCont = null + this.$treeScroll = null + this.treeScrollHeight = null + + this.$mainContentScrollable = null + this.mainContentScrollableWidth = null + this.mainContentScrollableOffsetLeft = null + this.$backTopBtn = null + this.entriesPanel = null + this.collapsedNestableState = null + + this.maintreeElementNameRightClick = this.maintreeElementNameRightClick.bind(this) + this.onNestableNodeTreeChange = this.onNestableNodeTreeChange.bind(this) + this.onNestableTagTreeChange = this.onNestableTagTreeChange.bind(this) + this.onNestableFolderTreeChange = this.onNestableFolderTreeChange.bind(this) + this.backTopBtnClick = this.backTopBtnClick.bind(this) + this.resize = this.resize.bind(this) + this.onNestableCollapse = this.onNestableCollapse.bind(this) + this.onNestableExpand = this.onNestableExpand.bind(this) + } + + onDocumentReady() { + /* + * Store Rozier configuration + */ + for (let index in window.temp) { + window.Rozier[index] = window.temp[index] + } + + /* + * override default nestable settings in order to + * store toggle state between reloads. + */ + if (window.localStorage) { + this.collapsedNestableState = window.localStorage.getItem('collapsed.uk.nestable') + /* + * First login into backoffice + */ + if (!this.collapsedNestableState) { + this.saveCollapsedNestableState(null) + this.collapsedNestableState = window.localStorage.getItem('collapsed.uk.nestable') + } + this.collapsedNestableState = JSON.parse(this.collapsedNestableState) + + window.UIkit.on('beforeready.uk.dom', function () { + $.extend(window.UIkit.components.nestable.prototype, { + collapseItem: function (li) { + var lists = li.children(this.options._listClass) + if (lists.length) { + li.addClass(this.options.collapsedClass) + } + /* + * Create new event on collapse + */ + document.dispatchEvent( + new CustomEvent('collapse.uk.nestable', { + detail: li, + }) + ) + }, + }) + $.extend(window.UIkit.components.nestable.prototype, { + expandItem: function (li) { + li.removeClass(this.options.collapsedClass) + /* + * Create new event on expand + */ + document.dispatchEvent( + new CustomEvent('expand.uk.nestable', { + detail: li, + }) + ) + }, + }) + }) + } + + this.lazyload = new Lazyload() + this.entriesPanel = new EntriesPanel() + this.vueApp = new VueApp() + + this.$window = $(window) + this.$body = $('body') + + // --- Selectors --- // + this.$userPanelContainer = $('#user-panel-container') + this.$minifyTreePanelButton = $('#minify-tree-panel-button') + this.$mainTrees = $('#main-trees') + this.$mainTreesContainer = $('#main-trees-container') + this.$nodesSourcesSearch = $('#nodes-sources-search') + this.$mainContentScrollable = $('#main-content-scrollable') + this.$backTopBtn = $('#back-top-button') + + // Pointer events polyfill + if (!window.Modernizr.testProp('pointerEvents')) { + PointerEventsPolyfill.initialize({ selector: '#main-trees-overlay' }) + } + + // Minify trees panel toggle button + this.$minifyTreePanelButton.on('click', this.toggleTreesPanel) + + // this.$body.on('markdownPreviewOpen', '.markdown-editor-preview', this.toggleTreesPanel); + document.body.addEventListener('markdownPreviewOpen', this.openTreesPanel, false) + + // Back top btn + this.$backTopBtn.on('click', this.backTopBtnClick) + + this.$window.on('resize', this.resize) + this.$window.trigger('resize') + + this.lazyload.generalBind() + this.bindMainNodeTreeLangs() + + /* + * Fetch main tree widgets for the first time + */ + this.refreshMainNodeTree() + this.refreshMainTagTree() + this.refreshMainFolderTree() + } + + saveCollapsedNestableState(state = null) { + if (state === null) { + state = { + nodes: [], + tags: [], + folders: [], + } + } + window.localStorage.setItem('collapsed.uk.nestable', JSON.stringify(state)) + } + + /** + * init nestable for ajax + * @return {[type]} [description] + */ + initNestables() { + this.collapsedNestableState.nodes.forEach((value) => { + const li = $('.uk-nestable-item[data-node-id="' + $.escapeSelector(value) + '"]') + if (li.length) { + li[0].classList.add('uk-collapsed') + } + }) + this.collapsedNestableState.tags.forEach((value) => { + const li = $('.uk-nestable-item[data-tag-id="' + $.escapeSelector(value) + '"]') + if (li.length) { + li[0].classList.add('uk-collapsed') + } + }) + this.collapsedNestableState.folders.forEach((value) => { + const li = $('.uk-nestable-item[data-folder-id="' + $.escapeSelector(value) + '"]') + if (li.length) { + li[0].classList.add('uk-collapsed') + } + }) + + $('.uk-nestable').each((index, element) => { + let $tree = $(element) + /* + * make drag&drop only available on handle + * very important for Touch based device which need to + * scroll on trees. + */ + let options = { + handleClass: 'uk-nestable-handle', + } + + if ($tree.hasClass('nodetree')) { + options.group = 'nodeTree' + } else if ($tree.hasClass('tagtree')) { + options.group = 'tagTree' + } else if ($tree.hasClass('foldertree')) { + options.group = 'folderTree' + } + + window.UIkit.nestable(element, options) + }) + document.removeEventListener('collapse.uk.nestable', this.onNestableCollapse) + document.addEventListener('collapse.uk.nestable', this.onNestableCollapse) + document.removeEventListener('expand.uk.nestable', this.onNestableExpand) + document.addEventListener('expand.uk.nestable', this.onNestableExpand) + } + + /** + * Bind main trees + */ + bindMainTrees() { + // TREES + let $nodeTree = $('.nodetree-widget .root-tree') + $nodeTree.off('change.uk.nestable') + $nodeTree.on('change.uk.nestable', this.onNestableNodeTreeChange) + + let $tagTree = $('.tagtree-widget .root-tree') + $tagTree.off('change.uk.nestable') + $tagTree.on('change.uk.nestable', this.onNestableTagTreeChange) + + let $folderTree = $('.foldertree-widget .root-tree') + $folderTree.off('change.uk.nestable') + $folderTree.on('change.uk.nestable', this.onNestableFolderTreeChange) + + // Tree element name + this.$mainTreeElementName = this.$mainTrees.find('.tree-element-name') + if (this.$mainTreeElementName.length) { + this.$mainTreeElementName.off('contextmenu', this.maintreeElementNameRightClick) + this.$mainTreeElementName.on('contextmenu', this.maintreeElementNameRightClick) + } + } + + /** + * Main tree element name right click. + * @return {boolean} + */ + maintreeElementNameRightClick(e) { + let $contextualMenu = $(e.currentTarget).parent().find('.tree-contextualmenu') + if ($contextualMenu.length) { + if ($contextualMenu[0].className.indexOf('uk-open') === -1) { + $contextualMenu.addClass('uk-open') + } else $contextualMenu.removeClass('uk-open') + } + + return false + } + + /** + * Bind main node tree langs. + * + * @return {boolean} + */ + bindMainNodeTreeLangs() { + $('body').on('click', '#tree-container .nodetree-langs a', (event) => { + this.lazyload.canvasLoader.show() + let $link = $(event.currentTarget) + let translationId = parseInt($link.attr('data-translation-id')) + this.refreshMainNodeTree(translationId) + return false + }) + } + + /** + * Get messages. + */ + getMessages() { + $.ajax({ + url: this.routes.ajaxSessionMessages, + type: 'GET', + dataType: 'json', + cache: false, + data: { + _action: 'messages', + _token: this.ajaxToken, + }, + }) + .done((data) => { + if (typeof data.messages !== 'undefined') { + if (typeof data.messages.confirm !== 'undefined' && data.messages.confirm.length > 0) { + for (let i = data.messages.confirm.length - 1; i >= 0; i--) { + window.UIkit.notify({ + message: data.messages.confirm[i], + status: 'success', + timeout: 2000, + pos: 'top-center', + }) + } + } + + if (typeof data.messages.error !== 'undefined' && data.messages.error.length > 0) { + for (let j = data.messages.error.length - 1; j >= 0; j--) { + window.UIkit.notify({ + message: data.messages.error[j], + status: 'error', + timeout: 2000, + pos: 'top-center', + }) + } + } + } + }) + .fail(() => { + console.log('[Rozier.getMessages] error') + }) + } + + /** + * @param translationId + */ + refreshAllNodeTrees(translationId) { + this.refreshMainNodeTree(translationId) + + /* + * Stack trees + */ + if (this.lazyload.stackNodeTrees.treeAvailable()) { + this.lazyload.stackNodeTrees.refreshNodeTree() + } + + /* + * Children node fields widgets; + */ + if (this.lazyload.childrenNodesFields.treeAvailable()) { + for (let i = this.lazyload.childrenNodesFields.$nodeTrees.length - 1; i >= 0; i--) { + let $nodeTree = this.lazyload.childrenNodesFields.$nodeTrees.eq(i) + this.lazyload.childrenNodesFields.refreshNodeTree($nodeTree) + } + } + } + + /** + * Refresh only main nodeTree. + * + * @param translationId + */ + refreshMainNodeTree(translationId) { + let $currentNodeTree = $('#tree-container').find('.nodetree-widget') + let $currentRootTree = $currentNodeTree.find('.root-tree').eq(0) + + if ($currentNodeTree.length) { + let postData = { + _token: this.ajaxToken, + _action: 'requestMainNodeTree', + } + + if ($currentRootTree.length && !translationId) { + translationId = parseInt($currentRootTree.attr('data-translation-id')) + } + + let url = this.routes.nodesTreeAjax + if (translationId && translationId > 0) { + url += '/' + translationId + } + + $.ajax({ + url: url, + type: 'get', + cache: false, + dataType: 'json', + data: postData, + }) + .done((data) => { + if ($currentNodeTree.length && typeof data.nodeTree !== 'undefined') { + $currentNodeTree.fadeOut('slow', () => { + $currentNodeTree.replaceWith(data.nodeTree) + $currentNodeTree = $('#tree-container').find('.nodetree-widget') + $currentNodeTree.fadeIn() + this.initNestables() + this.bindMainTrees() + this.resize() + this.lazyload.bindAjaxLink() + + if (this.lazyload.nodeTreeContextActions) { + this.lazyload.nodeTreeContextActions.unbind() + } + + this.lazyload.nodeTreeContextActions = new NodeTreeContextActions() + }) + } + }) + .fail(() => { + console.log('[Rozier.refreshMainNodeTree] Retrying in 3 seconds') + // Wait for background jobs to be done + setTimeout(() => { + this.refreshMainNodeTree(translationId) + }, 3000) + }) + .always(() => { + this.lazyload.canvasLoader.hide() + }) + } else { + console.debug('No main node-tree available.') + } + } + + /** + * Refresh only main tagTree. + * + */ + refreshMainTagTree() { + let $currentTagTree = $('#tree-container').find('.tagtree-widget') + + if ($currentTagTree.length) { + let postData = { + _token: this.ajaxToken, + _action: 'requestMainTagTree', + } + + let url = this.routes.tagsTreeAjax + + $.ajax({ + url: url, + type: 'get', + cache: false, + dataType: 'json', + data: postData, + }) + .done((data) => { + if ($currentTagTree.length && typeof data.tagTree !== 'undefined') { + $currentTagTree.fadeOut('slow', () => { + $currentTagTree.replaceWith(data.tagTree) + $currentTagTree = $('#tree-container').find('.tagtree-widget') + $currentTagTree.fadeIn() + this.initNestables() + this.bindMainTrees() + this.resize() + this.lazyload.bindAjaxLink() + }) + } + }) + .always(() => { + this.lazyload.canvasLoader.hide() + }) + } else { + console.debug('No main tag-tree available.') + } + } + + /** + * Refresh only main folderTree. + */ + refreshMainFolderTree() { + let $currentFolderTree = $('#tree-container').find('.foldertree-widget') + + if ($currentFolderTree.length) { + let postData = { + _token: this.ajaxToken, + _action: 'requestMainFolderTree', + } + + let url = this.routes.foldersTreeAjax + + $.ajax({ + url: url, + type: 'get', + cache: false, + dataType: 'json', + data: postData, + }) + .done((data) => { + if ($currentFolderTree.length && typeof data.folderTree !== 'undefined') { + $currentFolderTree.fadeOut('slow', () => { + $currentFolderTree.replaceWith(data.folderTree) + $currentFolderTree = $('#tree-container').find('.foldertree-widget') + $currentFolderTree.fadeIn() + this.initNestables() + this.bindMainTrees() + this.resize() + this.lazyload.bindAjaxLink() + }) + } + }) + .always(() => { + this.lazyload.canvasLoader.hide() + }) + } else { + console.debug('No main folder-tree available.') + } + } + + /** + * Toggle trees panel + * @param {[type]} event [description] + * @return {[type]} [description] + */ + toggleTreesPanel() { + $('#main-container-inner').toggleClass('trees-panel--minified') + $('#main-content').toggleClass('maximized') + $('#minify-tree-panel-button').find('i').toggleClass('uk-icon-rz-panel-tree-open') + $('#minify-tree-panel-area').toggleClass('tree-panel-hidden') + + return false + } + + openTreesPanel() { + if ($('#main-container-inner').hasClass('trees-panel--minified')) { + this.toggleTreesPanel(null) + } + + return false + } + + /** + * Toggle user panel + * @param {[type]} event [description] + * @return {[type]} [description] + */ + toggleUserPanel() { + $('#user-panel').toggleClass('minified') + return false + } + + onNestableCollapse({ detail }) { + if (detail[0]) { + switch (true) { + case detail[0].getAttribute('data-node-id') !== null: + this.collapsedNestableState.nodes.push(detail[0].getAttribute('data-node-id')) + break + case detail[0].getAttribute('data-tag-id') !== null: + this.collapsedNestableState.tags.push(detail[0].getAttribute('data-tag-id')) + break + case detail[0].getAttribute('data-folder-id') !== null: + this.collapsedNestableState.folders.push(detail[0].getAttribute('data-folder-id')) + break + } + + this.saveCollapsedNestableState(this.collapsedNestableState) + } + } + + onNestableExpand({ detail }) { + if (detail[0]) { + switch (true) { + case detail[0].getAttribute('data-node-id') !== null: + this.collapsedNestableState.nodes.splice( + this.collapsedNestableState.nodes.indexOf(detail[0].getAttribute('data-node-id')), + 1 + ) + break + case detail[0].getAttribute('data-tag-id') !== null: + this.collapsedNestableState.tags.splice( + this.collapsedNestableState.tags.indexOf(detail[0].getAttribute('data-tag-id')), + 1 + ) + break + case detail[0].getAttribute('data-folder-id') !== null: + this.collapsedNestableState.folders.splice( + this.collapsedNestableState.folders.indexOf(detail[0].getAttribute('data-folder-id')), + 1 + ) + break + } + + this.saveCollapsedNestableState(this.collapsedNestableState) + } + } + + /** + * @param event + * @param rootEl + * @param el + * @param status + * @returns {boolean} + */ + onNestableNodeTreeChange(event, rootEl, el, status) { + let element = $(el) + /* + * If node removed, do not do anything, the other change.uk.nestable nodeTree will be triggered + */ + if (status === 'removed') { + return false + } + let nodeId = parseInt(element.attr('data-node-id')) + let parentNodeId = null + if (element.parents('.nodetree-element').length) { + parentNodeId = parseInt(element.parents('.nodetree-element').eq(0).attr('data-node-id')) + } else if (element.parents('.stack-tree-widget').length) { + parentNodeId = parseInt(element.parents('.stack-tree-widget').eq(0).attr('data-parent-node-id')) + } else if (element.parents('.children-node-widget').length) { + parentNodeId = parseInt(element.parents('.children-node-widget').eq(0).attr('data-parent-node-id')) + } + + /* + * When dropping to route + * set parentNodeId to NULL + */ + if (isNaN(parentNodeId)) { + parentNodeId = null + } + + /* + * User dragged node inside itself + * It will destroy the Internet ! + */ + if (nodeId === parentNodeId) { + console.error('You cannot move a node inside itself!') + window.location.reload() + return false + } + + let postData = { + _token: this.ajaxToken, + _action: 'updatePosition', + nodeId: nodeId, + newParent: parentNodeId, + } + + /* + * Get node siblings id to compute new position + */ + if (element.next().length && typeof element.next().attr('data-node-id') !== 'undefined') { + postData.nextNodeId = parseInt(element.next().attr('data-node-id')) + } else if (element.prev().length && typeof element.prev().attr('data-node-id') !== 'undefined') { + postData.prevNodeId = parseInt(element.prev().attr('data-node-id')) + } + + $.ajax({ + url: this.routes.nodeAjaxEdit.replace('%nodeId%', nodeId), + type: 'POST', + dataType: 'json', + data: postData, + }) + .done((data) => { + window.UIkit.notify({ + message: data.responseText, + status: data.status, + timeout: 3000, + pos: 'top-center', + }) + }) + .fail((data) => { + data = JSON.parse(data.responseText) + window.UIkit.notify({ + message: data.error_message, + status: 'danger', + timeout: 3000, + pos: 'top-center', + }) + }) + } + + /** + * @param event + * @param rootEl + * @param el + * @param status + * @returns {boolean} + */ + onNestableTagTreeChange(event, rootEl, el, status) { + let element = $(el) + + /* + * If tag removed, do not do anything, the other tagTree will be triggered + */ + if (status === 'removed') { + return false + } + + let tagId = parseInt(element.attr('data-tag-id')) + let parentTagId = null + if (element.parents('.tagtree-element').length) { + parentTagId = parseInt(element.parents('.tagtree-element').eq(0).attr('data-tag-id')) + } else if (element.parents('.root-tree').length) { + parentTagId = parseInt(element.parents('.root-tree').eq(0).attr('data-parent-tag-id')) + } + /* + * When dropping to route + * set parentTagId to NULL + */ + if (isNaN(parentTagId)) { + parentTagId = null + } + + /* + * User dragged tag inside itself + * It will destroy the Internet ! + */ + if (tagId === parentTagId) { + console.error('You cannot move a tag inside itself!') + alert('You cannot move a tag inside itself!') + window.location.reload() + return false + } + + let postData = { + _token: this.ajaxToken, + _action: 'updatePosition', + tagId: tagId, + newParent: parentTagId, + } + + /* + * Get tag siblings id to compute new position + */ + if (element.next().length && typeof element.next().attr('data-tag-id') !== 'undefined') { + postData.nextTagId = parseInt(element.next().attr('data-tag-id')) + } else if (element.prev().length && typeof element.prev().attr('data-tag-id') !== 'undefined') { + postData.prevTagId = parseInt(element.prev().attr('data-tag-id')) + } + + $.ajax({ + url: this.routes.tagAjaxEdit.replace('%tagId%', tagId), + type: 'POST', + dataType: 'json', + data: postData, + }).done((data) => { + window.UIkit.notify({ + message: data.responseText, + status: data.status, + timeout: 3000, + pos: 'top-center', + }) + }) + } + + /** + * + * @param event + * @param element + * @param status + * @returns {boolean} + */ + onNestableFolderTreeChange(event, rootEl, el, status) { + let element = $(el) + /* + * If folder removed, do not do anything, the other folderTree will be triggered + */ + if (status === 'removed') { + return false + } + + let folderId = parseInt(element.attr('data-folder-id')) + let parentFolderId = null + + if (element.parents('.foldertree-element').length) { + parentFolderId = parseInt(element.parents('.foldertree-element').eq(0).attr('data-folder-id')) + } else if (element.parents('.root-tree').length) { + parentFolderId = parseInt(element.parents('.root-tree').eq(0).attr('data-parent-folder-id')) + } + + /* + * When dropping to route + * set parentFolderId to NULL + */ + if (isNaN(parentFolderId)) { + parentFolderId = null + } + + /* + * User dragged folder inside itself + * It will destroy the Internet ! + */ + if (folderId === parentFolderId) { + console.error('You cannot move a folder inside itself!') + alert('You cannot move a folder inside itself!') + window.location.reload() + return false + } + + let postData = { + _token: this.ajaxToken, + _action: 'updatePosition', + folderId: folderId, + newParent: parentFolderId, + } + + /* + * Get folder siblings id to compute new position + */ + if (element.next().length && typeof element.next().attr('data-folder-id') !== 'undefined') { + postData.nextFolderId = parseInt(element.next().attr('data-folder-id')) + } else if (element.prev().length && typeof element.prev().attr('data-folder-id') !== 'undefined') { + postData.prevFolderId = parseInt(element.prev().attr('data-folder-id')) + } + + $.ajax({ + url: this.routes.folderAjaxEdit.replace('%folderId%', folderId), + type: 'POST', + dataType: 'json', + data: postData, + }).done((data) => { + window.UIkit.notify({ + message: data.responseText, + status: data.status, + timeout: 3000, + pos: 'top-center', + }) + }) + } + + /** + * Back top click + * @return {boolean} [description] + */ + backTopBtnClick() { + TweenLite.to(this.$mainContentScrollable, 0.6, { scrollTo: { y: 0 }, ease: Expo.easeOut }) + return false + } + + /** + * Resize + * @return {[type]} [description] + */ + resize() { + this.windowWidth = this.$window.width() + this.windowHeight = this.$window.height() + + // Close tree panel if small screen & first resize + if (this.windowWidth >= 768 && this.windowWidth <= 1200 && this.$mainTrees.length && this.resizeFirst) { + this.$mainTrees[0].style.display = 'none' + this.$minifyTreePanelButton.trigger('click') + window.setTimeout(() => { + this.$mainTrees[0].style.display = 'table-cell' + }, 1000) + } + + // Check if mobile + if (this.windowWidth <= 768 && this.resizeFirst) { + this.mobile = new RozierMobile() + } + + if (this.$mainTreesContainer.length && this.$mainContentScrollable.length) { + if (this.windowWidth >= 768) { + this.$mainContentScrollable.height(this.windowHeight) + this.$mainTreesContainer[0].style.height = '' + } else { + this.$mainContentScrollable[0].style.height = '' + this.$mainTreesContainer.height(this.windowHeight) + } + } + + // Tree scroll height + if (this.$mainTrees.length) { + this.$nodeTreeHead = this.$mainTrees.find('.nodetree-head') + this.$treeScrollCont = this.$mainTrees.find('.tree-scroll-cont') + this.$treeScroll = this.$mainTrees.find('.tree-scroll') + + /* + * need actual to get tree height even when they are hidden. + */ + this.nodesSourcesSearchHeight = this.$nodesSourcesSearch.actual('outerHeight') + this.nodeTreeHeadHeight = this.$nodeTreeHead.actual('outerHeight') + this.treeScrollHeight = this.windowHeight - (this.nodesSourcesSearchHeight + this.nodeTreeHeadHeight) + + if (this.mobile !== null) { + this.treeScrollHeight = this.windowHeight - (50 + 50 + this.nodeTreeHeadHeight) + } // Menu + tree menu + tree head + + for (let i = 0; i < this.$treeScrollCont.length; i++) { + this.$treeScrollCont[i].style.height = this.treeScrollHeight + 'px' + } + } + + // Main content + this.mainContentScrollableWidth = this.$mainContentScrollable.width() + this.mainContentScrollableOffsetLeft = this.windowWidth - this.mainContentScrollableWidth + + this.lazyload.resize() + this.entriesPanel.replaceSubNavs() + + // Documents list + // if(this.lazyload !== null && !this.resizeFirst) this.lazyload.documentsList.resize(); + + // Set resize first to false + if (this.resizeFirst) this.resizeFirst = false + } +} diff --git a/lib/Rozier/src/Resources/app/RozierMobile.js b/lib/Rozier/src/Resources/app/RozierMobile.js new file mode 100644 index 00000000..b2cf8cb3 --- /dev/null +++ b/lib/Rozier/src/Resources/app/RozierMobile.js @@ -0,0 +1,347 @@ +import $ from 'jquery' +import { TweenLite, Expo } from 'gsap' +import { addClass, removeClass } from './utils/plugins' + +/** + * Rozier Mobile + */ +export default class RozierMobile { + constructor() { + // Selectors + this.$menu = $('#menu-mobile') + this.$adminMenu = $('#admin-menu') + this.$adminMenuLink = this.$adminMenu.find('a') + this.$adminMenuNavParent = this.$adminMenu.find('.uk-parent') + + this.$searchButton = $('#search-button') + this.$searchPanel = $('#nodes-sources-search') + this.$treeButton = $('#tree-button') + this.$treeWrapper = $('#tree-wrapper') + this.$treeWrapperLink = this.$treeWrapper.find('a') + this.$userPicture = $('#user-picture') + this.$userActions = $('.user-actions') + this.$userActionsLink = this.$userActions.find('a') + this.$mainContentOverlay = $('#main-content-overlay') + + this.menuOpen = false + this.searchOpen = false + this.treeOpen = false + this.adminOpen = false + + this.menuClick = this.menuClick.bind(this) + this.adminMenuLinkClick = this.adminMenuLinkClick.bind(this) + this.adminMenuNavParentClick = this.adminMenuNavParentClick.bind(this) + this.searchButtonClick = this.searchButtonClick.bind(this) + this.treeButtonClick = this.treeButtonClick.bind(this) + this.treeWrapperLinkClick = this.treeWrapperLinkClick.bind(this) + this.userPictureClick = this.userPictureClick.bind(this) + this.userActionsLinkClick = this.userActionsLinkClick.bind(this) + this.mainContentOverlayClick = this.mainContentOverlayClick.bind(this) + + // Methods + this.init() + } + /** + * Init + * @return {[type]} [description] + */ + init() { + if (this.$userPicture.length) { + // Add class on user picture link to unbind default event + addClass(this.$userPicture[0], 'rz-no-ajax-link') + } + // Events + this.$menu.on('click', this.menuClick) + this.$adminMenuLink.on('click', this.adminMenuLinkClick) + this.$adminMenuNavParent.on('click', this.adminMenuNavParentClick) + this.$searchButton.on('click', this.searchButtonClick) + this.$treeButton.on('click', this.treeButtonClick) + this.$treeWrapperLink.on('click', this.treeWrapperLinkClick) + this.$userPicture.on('click', this.userPictureClick) + this.$userActionsLink.on('click', this.userActionsLinkClick) + this.$mainContentOverlay.on('click', this.mainContentOverlayClick) + + window.addEventListener('pageload', this.mainContentOverlayClick) + } + + /** + * Menu click + * @return {[type]} [description] + */ + menuClick(e) { + if (!this.menuOpen) this.openMenu() + else this.closeMenu() + } + + /** + * Admin menu nav parent click + * @return {[type]} [description] + */ + adminMenuNavParentClick(e) { + let $target = $(e.currentTarget) + let $ukNavSub = $(e.currentTarget).find('.uk-nav-sub') + + // Open + if (!$target.hasClass('nav-open')) { + let $ukNavSubItem = $ukNavSub.find('.uk-nav-sub-item') + let ukNavSubHeight = $ukNavSubItem.length * 41 - 3 + + $ukNavSub[0].style.display = 'block' + TweenLite.to($ukNavSub, 0.6, { height: ukNavSubHeight, ease: Expo.easeOut, onComplete: function () {} }) + + $target.addClass('nav-open') + } else { + // Close + TweenLite.to($ukNavSub, 0.6, { + height: 0, + ease: Expo.easeOut, + onComplete: function () { + $ukNavSub[0].style.display = 'none' + }, + }) + + $target.removeClass('nav-open') + } + } + + /** + * Admin menu link click + * @return {[type]} [description] + */ + adminMenuLinkClick(e) { + if (this.menuOpen) this.closeMenu() + } + + /** + * Open menu + * @return {[type]} [description] + */ + openMenu() { + // Close panel if open + this.closeSearch() + this.closeTree() + this.closeUser() + + // Translate menu panel + TweenLite.to(this.$adminMenu, 0.6, { x: 0, ease: Expo.easeOut }) + if (this.$mainContentOverlay.length) { + this.$mainContentOverlay[0].style.display = 'block' + TweenLite.to(this.$mainContentOverlay, 0.6, { opacity: 0.5, ease: Expo.easeOut }) + } + this.menuOpen = true + } + + /** + * Close menu + * @return {[type]} [description] + */ + closeMenu() { + let adminMenuX = -window.Rozier.windowWidth * 0.8 + TweenLite.to(this.$adminMenu, 0.6, { x: adminMenuX, ease: Expo.easeOut }) + TweenLite.to(this.$mainContentOverlay, 0.6, { + opacity: 0, + ease: Expo.easeOut, + onComplete: () => { + if (this.$mainContentOverlay.length) { + this.$mainContentOverlay[0].style.display = 'none' + } + }, + }) + + this.menuOpen = false + } + + /** + * Search button click + * @return {[type]} [description] + */ + searchButtonClick(e) { + if (!this.searchOpen) this.openSearch() + else this.closeSearch() + } + + /** + * Open search + * @return {[type]} [description] + */ + openSearch() { + // Close panel if open + this.closeMenu() + this.closeTree() + this.closeUser() + + // Translate search panel + TweenLite.to(this.$searchPanel, 0.6, { x: 0, ease: Expo.easeOut }) + + if (this.$mainContentOverlay.length) { + this.$mainContentOverlay[0].style.display = 'block' + TweenLite.to(this.$mainContentOverlay, 0.6, { opacity: 0.5, ease: Expo.easeOut }) + } + + // Add active class + this.$searchButton.addClass('active') + this.searchOpen = true + } + + /** + * Close search + * @return {[type]} [description] + */ + closeSearch() { + let searchPanelX = -window.Rozier.windowWidth * 0.8 + TweenLite.to(this.$searchPanel, 0.6, { x: searchPanelX, ease: Expo.easeOut }) + TweenLite.to(this.$mainContentOverlay, 0.6, { + opacity: 0, + ease: Expo.easeOut, + onComplete: () => { + this.$mainContentOverlay[0].style.display = 'none' + }, + }) + + // Remove active class + this.$searchButton.removeClass('active') + this.searchOpen = false + } + + /** + * Tree button click + * @return {[type]} [description] + */ + treeButtonClick(e) { + if (!this.treeOpen) this.openTree() + else this.closeTree() + } + + /** + * Tree wrapper link click + * @return {[type]} [description] + */ + treeWrapperLinkClick(e) { + if (e.currentTarget.className.indexOf('tab-link') === -1 && this.treeOpen) { + this.closeTree() + } + } + + /** + * Open tree + * @return {[type]} [description] + */ + openTree() { + // Close panel if open + this.closeMenu() + this.closeSearch() + this.closeUser() + + // Translate tree panel + TweenLite.to(this.$treeWrapper, 0.6, { x: 0, ease: Expo.easeOut }) + + this.$mainContentOverlay[0].style.display = 'block' + TweenLite.to(this.$mainContentOverlay, 0.6, { opacity: 0.5, ease: Expo.easeOut }) + + // Add active class + this.$treeButton.addClass('active') + this.treeOpen = true + } + + /** + * Close tree + * @return {[type]} [description] + */ + closeTree() { + let treeWrapperX = -window.Rozier.windowWidth * 0.8 + + TweenLite.to(this.$treeWrapper, 0.6, { x: treeWrapperX, ease: Expo.easeOut }) + TweenLite.to(this.$mainContentOverlay, 0.6, { + opacity: 0, + ease: Expo.easeOut, + onComplete: () => { + this.$mainContentOverlay[0].style.display = 'none' + }, + }) + + // Remove active class + removeClass(this.$treeButton[0], 'active') + + this.treeOpen = false + } + + /** + * User picture click + * @return {[type]} [description] + */ + userPictureClick(e) { + if (!this.userOpen) this.openUser() + else this.closeUser() + return false + } + + /** + * User actions link click + * @return {[type]} [description] + */ + userActionsLinkClick(e) { + if (this.userOpen) { + this.closeUser() + } + } + + /** + * Open user + * @return {[type]} [description] + */ + openUser() { + // Close panel if open + this.closeMenu() + this.closeSearch() + this.closeTree() + + // Translate user panel + TweenLite.to(this.$userActions, 0.6, { x: 0, ease: Expo.easeOut }) + + if (this.$mainContentOverlay.length) { + this.$mainContentOverlay[0].style.display = 'block' + TweenLite.to(this.$mainContentOverlay, 0.6, { opacity: 0.5, ease: Expo.easeOut }) + } + + // Add active class + this.$userPicture.addClass('active') + this.userOpen = true + } + + /** + * Close user + * @return {[type]} [description] + */ + closeUser() { + let userActionsX = window.Rozier.windowWidth * 0.8 + TweenLite.to(this.$userActions, 0.6, { x: userActionsX, ease: Expo.easeOut }) + TweenLite.to(this.$mainContentOverlay, 0.6, { + opacity: 0, + ease: Expo.easeOut, + onComplete: () => { + this.$mainContentOverlay[0].style.display = 'none' + }, + }) + + // Remove active class + this.$userPicture.removeClass('active') + this.userOpen = false + } + + /** + * Main content overlay click + * @return {[type]} [description] + */ + mainContentOverlayClick(e) { + this.closeMenu() + this.closeTree() + this.closeUser() + this.closeSearch() + } + + /** + * Window resize callback + * @return {[type]} [description] + */ + resize() {} +} diff --git a/lib/Rozier/src/Resources/app/api/CustomFormApi.js b/lib/Rozier/src/Resources/app/api/CustomFormApi.js new file mode 100644 index 00000000..87c8b002 --- /dev/null +++ b/lib/Rozier/src/Resources/app/api/CustomFormApi.js @@ -0,0 +1,77 @@ +import request from 'axios' + +/** + * Fetch Joins from an array of node id. + * + * @param {Array} ids + * @param filters + * @returns {Promise|Promise.} + */ +export function getCustomFormsByIds({ ids = [], filters }) { + const postData = { + _token: window.RozierRoot.ajaxToken, + ids: ids, + } + + return request({ + method: 'GET', + url: window.RozierRoot.routes.customFormsAjaxByArray, + params: postData, + }) + .then((response) => { + if (typeof response.data !== 'undefined' && response.data.forms) { + return { + items: response.data.forms, + } + } else { + return null + } + }) + .catch((error) => { + // TODO + // Log request error or display a message + throw new Error(error.response.data.humanMessage) + }) +} + +/** + * Fetch Joins from search terms. + * + * @param {String} searchTerms + * @param {Object} filters + * @param {Boolean} moreData + * @returns {Promise.|Promise} + */ +export function getCustomForms({ searchTerms, filters, moreData }) { + const postData = { + _token: window.RozierRoot.ajaxToken, + _action: 'toggleExplorer', + search: searchTerms, + page: 1, + } + + if (moreData) { + postData.page = filters ? filters.nextPage : 1 + } + + return request({ + method: 'GET', + url: window.RozierRoot.routes.customFormsAjaxExplorer, + params: postData, + }) + .then((response) => { + if (typeof response.data !== 'undefined' && response.data.customForms) { + return { + items: response.data.customForms, + filters: response.data.filters, + } + } else { + return {} + } + }) + .catch((error) => { + // TODO + // Log request error or display a message + throw new Error(error) + }) +} diff --git a/lib/Rozier/src/Resources/app/api/DocumentApi.js b/lib/Rozier/src/Resources/app/api/DocumentApi.js new file mode 100644 index 00000000..9729e172 --- /dev/null +++ b/lib/Rozier/src/Resources/app/api/DocumentApi.js @@ -0,0 +1,90 @@ +import request from 'axios' + +/** + * Fetch Documents from an array of document id. + * + * @param {Array} ids + * @returns {Promise|Promise.} + */ +export function getDocumentsByIds({ ids = [] }) { + const postData = { + _token: window.RozierRoot.ajaxToken, + _action: 'documentsByIds', + ids: ids, + } + + return request({ + method: 'GET', + url: window.RozierRoot.routes.documentsAjaxByArray, + params: postData, + }) + .then((response) => { + if (typeof response.data !== 'undefined' && response.data.documents) { + return { + items: response.data.documents, + trans: response.data.trans, + } + } else { + return null + } + }) + .catch((error) => { + // TODO + // Log request error or display a message + throw new Error(error.response.data.humanMessage) + }) +} + +/** + * Fetch Documents from search terms. + * + * @param {String} searchTerms + * @param {Object} filters + * @param {Object} filterExplorerSelection + * @param {Boolean} moreData + * @return Promise + */ +export function getDocuments({ searchTerms, filters, filterExplorerSelection, moreData }) { + const postData = { + _token: window.RozierRoot.ajaxToken, + _action: 'toggleExplorer', + search: searchTerms, + page: 1, + } + + if (filterExplorerSelection && filterExplorerSelection.id) { + postData.folderId = filterExplorerSelection.id + } + + if (moreData) { + postData.page = filters ? filters.nextPage : 1 + } + + return request({ + method: 'GET', + url: window.RozierRoot.routes.documentsAjaxExplorer, + params: postData, + }) + .then((response) => { + if (typeof response.data !== 'undefined' && response.data.documents) { + return { + items: response.data.documents, + filters: response.data.filters, + trans: response.data.trans, + } + } else { + return {} + } + }) + .catch((error) => { + // TODO + // Log request error or display a message + throw new Error(error) + }) +} + +export function setDocument(formData) { + return request.post(window.location.href, formData, { + headers: { Accept: 'application/json' }, + }) +} diff --git a/lib/Rozier/src/Resources/app/api/DrawerApi.js b/lib/Rozier/src/Resources/app/api/DrawerApi.js new file mode 100644 index 00000000..be0dbd76 --- /dev/null +++ b/lib/Rozier/src/Resources/app/api/DrawerApi.js @@ -0,0 +1,45 @@ +import { + DOCUMENT_ENTITY, + NODE_ENTITY, + NODE_TYPE_ENTITY, + JOIN_ENTITY, + CUSTOM_FORM_ENTITY, + TAG_ENTITY, + EXPLORER_PROVIDER_ENTITY, +} from '../types/entityTypes' +import * as DocumentApi from './DocumentApi' +import * as NodeApi from './NodeApi' +import * as NodeTypeApi from './NodeTypeApi' +import * as JoinApi from './JoinApi' +import * as CustomFormApi from './CustomFormApi' +import * as TagApi from './TagApi' +import * as ExplorerProviderApi from './ExplorerProviderApi' + +/** + * Fetch Items from an array of ids. Depending of its entity type (document, node...). + * + * @param {String} entity + * @param {Array} ids + * @param filters + * @returns {Promise|Promise.} + */ +export function getItemsByIds(entity, ids = [], filters) { + switch (entity) { + case DOCUMENT_ENTITY: + return DocumentApi.getDocumentsByIds({ ids, filters }) + case NODE_ENTITY: + return NodeApi.getNodesByIds({ ids, filters }) + case NODE_TYPE_ENTITY: + return NodeTypeApi.getNodeTypesByIds({ ids, filters }) + case JOIN_ENTITY: + return JoinApi.getJoinsByIds({ ids, filters }) + case CUSTOM_FORM_ENTITY: + return CustomFormApi.getCustomFormsByIds({ ids, filters }) + case TAG_ENTITY: + return TagApi.getTagsByIds({ ids, filters }) + case EXPLORER_PROVIDER_ENTITY: + return ExplorerProviderApi.getItemsByIds({ ids, filters }) + } + + return Promise.reject(new Error('No type entity found')) +} diff --git a/lib/Rozier/src/Resources/app/api/ExplorerApi.js b/lib/Rozier/src/Resources/app/api/ExplorerApi.js new file mode 100644 index 00000000..53b96e93 --- /dev/null +++ b/lib/Rozier/src/Resources/app/api/ExplorerApi.js @@ -0,0 +1,55 @@ +import { + DOCUMENT_ENTITY, + NODE_ENTITY, + NODE_TYPE_ENTITY, + JOIN_ENTITY, + CUSTOM_FORM_ENTITY, + TAG_ENTITY, + EXPLORER_PROVIDER_ENTITY, +} from '../types/entityTypes' +import * as DocumentApi from './DocumentApi' +import * as NodeApi from './NodeApi' +import * as NodeTypeApi from './NodeTypeApi' +import * as JoinApi from './JoinApi' +import * as CustomFormApi from './CustomFormApi' +import * as TagApi from './TagApi' +import * as ExplorerProviderApi from './ExplorerProviderApi' + +/** + * Get items for the Explorer panel. Depending on its entity type (document, node...). + * + * @param {String} entity + * @param {String} searchTerms + * @param {Object} preFilters + * @param {Object} filters + * @param {Object} filterExplorerSelection + * @param moreData + * @returns {*} + */ +export function getExplorerItems({ + entity, + searchTerms, + preFilters, + filters, + filterExplorerSelection, + moreData = false, +}) { + switch (entity) { + case DOCUMENT_ENTITY: + return DocumentApi.getDocuments({ searchTerms, preFilters, filters, filterExplorerSelection, moreData }) + case NODE_ENTITY: + return NodeApi.getNodes({ searchTerms, preFilters, filters, filterExplorerSelection, moreData }) + case NODE_TYPE_ENTITY: + return NodeTypeApi.getNodeTypes({ searchTerms, preFilters, filters, filterExplorerSelection, moreData }) + case JOIN_ENTITY: + return JoinApi.getJoins({ searchTerms, preFilters, filters, filterExplorerSelection, moreData }) + case CUSTOM_FORM_ENTITY: + return CustomFormApi.getCustomForms({ searchTerms, preFilters, filters, filterExplorerSelection, moreData }) + case TAG_ENTITY: + return TagApi.getTags({ searchTerms, preFilters, filters, filterExplorerSelection, moreData }) + case EXPLORER_PROVIDER_ENTITY: + return ExplorerProviderApi.getItems({ searchTerms, preFilters, filters, filterExplorerSelection, moreData }) + } + + return Promise.reject(new Error('No type entity found')) +} diff --git a/lib/Rozier/src/Resources/app/api/ExplorerProviderApi.js b/lib/Rozier/src/Resources/app/api/ExplorerProviderApi.js new file mode 100644 index 00000000..acaa9547 --- /dev/null +++ b/lib/Rozier/src/Resources/app/api/ExplorerProviderApi.js @@ -0,0 +1,81 @@ +import request from 'axios' +import qs from 'qs' + +/** + * Fetch Joins from an array of node id. + * + * @param {Array} ids + * @param filters + * @returns {Promise|Promise.} + */ +export function getItemsByIds({ ids = [], filters }) { + const postData = { + _token: window.RozierRoot.ajaxToken, + ids: ids, + providerClass: filters.providerClass, + } + + return request({ + method: 'GET', + url: window.RozierRoot.routes.providerAjaxByArray, + params: postData, + }) + .then((response) => { + if (typeof response.data !== 'undefined' && response.data.items) { + return { + items: response.data.items, + } + } else { + return null + } + }) + .catch((error) => { + // Log request error or display a message + throw new Error(error.response.data.humanMessage) + }) +} + +/** + * Fetch Joins from search terms. + * + * @param {String} searchTerms + * @param {Object} preFilters + * @param {Object} filters + * @param {Object} filterExplorerSelection + * @param {Boolean} moreData + * @returns {Promise.|Promise} + */ +export function getItems({ searchTerms, preFilters, filters, filterExplorerSelection, moreData }) { + const postData = { + _token: window.RozierRoot.ajaxToken, + _action: 'toggleExplorer', + providerClass: preFilters ? preFilters.providerClass : null, + options: preFilters ? preFilters.providerOptions : null, + search: searchTerms, + page: 1, + } + + if (moreData) { + postData.page = filters ? filters.nextPage : 1 + } + + return request({ + method: 'GET', + url: window.RozierRoot.routes.providerAjaxExplorer + '?' + qs.stringify(postData), // need to use QS to compile array parameters + }) + .then((response) => { + if (typeof response.data !== 'undefined' && response.data.entities) { + return { + items: response.data.entities, + filters: response.data.filters, + } + } else { + return {} + } + }) + .catch((error) => { + // TODO + // Log request error or display a message + throw new Error(error) + }) +} diff --git a/lib/Rozier/src/Resources/app/api/FilterExplorerApi.js b/lib/Rozier/src/Resources/app/api/FilterExplorerApi.js new file mode 100644 index 00000000..3c094eef --- /dev/null +++ b/lib/Rozier/src/Resources/app/api/FilterExplorerApi.js @@ -0,0 +1,21 @@ +import * as FolderExplorerApi from './FolderExplorerApi' +import * as TagExplorerApi from './TagExplorerApi' +import { DOCUMENT_ENTITY, NODE_ENTITY, TAG_ENTITY } from '../types/entityTypes' + +/** + * Fetch filters. + * + * @return Promise + */ +export function getFilters({ entity }) { + switch (entity) { + case DOCUMENT_ENTITY: + return FolderExplorerApi.getFolders() + case NODE_ENTITY: + return TagExplorerApi.getTags() + case TAG_ENTITY: + return TagExplorerApi.getParentTags() + default: + return Promise.reject(new Error('Entity not found')) + } +} diff --git a/lib/Rozier/src/Resources/app/api/FolderExplorerApi.js b/lib/Rozier/src/Resources/app/api/FolderExplorerApi.js new file mode 100644 index 00000000..d591c8fd --- /dev/null +++ b/lib/Rozier/src/Resources/app/api/FolderExplorerApi.js @@ -0,0 +1,33 @@ +import request from 'axios' + +/** + * Fetch documents Folders. + * + * @return Promise + */ +export function getFolders() { + const postData = { + _token: window.RozierRoot.ajaxToken, + _action: 'foldersExplorer', + } + + return request({ + method: 'GET', + url: window.RozierRoot.routes.foldersAjaxExplorer, + params: postData, + }) + .then((response) => { + if (typeof response.data !== 'undefined' && response.data.folders) { + return { + items: response.data.folders, + } + } else { + return {} + } + }) + .catch((error) => { + // TODO + // Log request error or display a message + throw new Error(error) + }) +} diff --git a/lib/Rozier/src/Resources/app/api/JoinApi.js b/lib/Rozier/src/Resources/app/api/JoinApi.js new file mode 100644 index 00000000..3fc960ab --- /dev/null +++ b/lib/Rozier/src/Resources/app/api/JoinApi.js @@ -0,0 +1,77 @@ +import request from 'axios' + +/** + * Fetch Joins from an array of node id. + * + * @param {Array} ids + * @param filters + * @returns {Promise|Promise.} + */ +export function getJoinsByIds({ ids = [], filters }) { + const postData = { + _token: window.RozierRoot.ajaxToken, + ids: ids, + nodeTypeFieldId: filters.nodeTypeField, + } + + return request({ + method: 'GET', + url: window.RozierRoot.routes.joinsAjaxByArray, + params: postData, + }) + .then((response) => { + if (typeof response.data !== 'undefined' && response.data.items) { + return { + items: response.data.items, + } + } else { + return null + } + }) + .catch((error) => { + throw new Error(error.response.data.humanMessage) + }) +} + +/** + * Fetch Joins from search terms. + * + * @param {String} searchTerms + * @param {Object} preFilters + * @param {Object} filters + * @param {Object} filterExplorerSelection + * @param {Boolean} moreData + * @returns {Promise.|Promise} + */ +export function getJoins({ searchTerms, preFilters, filters, filterExplorerSelection, moreData }) { + const postData = { + _token: window.RozierRoot.ajaxToken, + _action: 'toggleExplorer', + nodeTypeFieldId: preFilters ? preFilters.nodeTypeField : null, + search: searchTerms, + page: 1, + } + + if (moreData) { + postData.page = filters ? filters.nextPage : 1 + } + + return request({ + method: 'GET', + url: window.RozierRoot.routes.joinsAjaxExplorer, + params: postData, + }) + .then((response) => { + if (typeof response.data !== 'undefined' && response.data.entities) { + return { + items: response.data.entities, + filters: response.data.filters, + } + } else { + return {} + } + }) + .catch((error) => { + throw new Error(error) + }) +} diff --git a/lib/Rozier/src/Resources/app/api/NodeApi.js b/lib/Rozier/src/Resources/app/api/NodeApi.js new file mode 100644 index 00000000..4cacc0d6 --- /dev/null +++ b/lib/Rozier/src/Resources/app/api/NodeApi.js @@ -0,0 +1,85 @@ +import request from 'axios' + +/** + * Fetch Nodes from an array of node id. + * + * @param {Array} ids + * @returns {Promise|Promise.} + */ +export function getNodesByIds({ ids = [] }) { + const postData = { + _token: window.RozierRoot.ajaxToken, + _action: 'nodesByIds', + ids: ids, + } + + return request({ + method: 'GET', + url: window.RozierRoot.routes.nodesAjaxByArray, + params: postData, + }) + .then((response) => { + if (typeof response.data !== 'undefined' && response.data.items) { + return { + items: response.data.items, + } + } else { + return null + } + }) + .catch((error) => { + throw new Error(error.response.data.humanMessage) + }) +} + +/** + * Fetch Nodes from search terms. + * + * @param {String} searchTerms + * @param {Object} preFilters + * @param {Object} filters + * @param {Object} filterExplorerSelection + * @param {Boolean} moreData + * @returns {Promise.|Promise} + */ +export function getNodes({ searchTerms, preFilters, filters, filterExplorerSelection, moreData }) { + const postData = { + _token: window.RozierRoot.ajaxToken, + _action: 'toggleExplorer', + search: searchTerms, + page: 1, + } + + if (filterExplorerSelection) { + if (filterExplorerSelection.id) { + postData.tagId = filterExplorerSelection.id + } + } + + if (preFilters && preFilters.nodeTypes) { + postData.nodeTypes = JSON.parse(preFilters.nodeTypes) + } + + if (moreData) { + postData.page = filters ? filters.nextPage : 1 + } + + return request({ + method: 'GET', + url: window.RozierRoot.routes.nodesAjaxExplorer, + params: postData, + }) + .then((response) => { + if (typeof response.data !== 'undefined' && response.data.nodes) { + return { + items: response.data.nodes, + filters: response.data.filters, + } + } else { + return {} + } + }) + .catch((error) => { + throw new Error(error) + }) +} diff --git a/lib/Rozier/src/Resources/app/api/NodeTypeApi.js b/lib/Rozier/src/Resources/app/api/NodeTypeApi.js new file mode 100644 index 00000000..a3309dc6 --- /dev/null +++ b/lib/Rozier/src/Resources/app/api/NodeTypeApi.js @@ -0,0 +1,80 @@ +import request from 'axios' + +/** + * Fetch NodeTypes from an array of node name. + * + * @param {Array} ids + * @returns {Promise|Promise.} + */ +export function getNodeTypesByIds({ ids = [] }) { + // Trim ids + ids = ids.map((item) => item.trim()) + + const postData = { + _token: window.RozierRoot.ajaxToken, + _action: 'nodeTypesByIds', + names: ids, + } + + return request({ + method: 'GET', + url: window.RozierRoot.routes.nodeTypesAjaxByArray, + params: postData, + }) + .then((response) => { + if (typeof response.data !== 'undefined' && response.data.items) { + return { + items: response.data.items, + } + } else { + return null + } + }) + .catch((error) => { + throw new Error(error.response.data.humanMessage) + }) +} + +/** + * Fetch NodeTypes from search terms. + * + * @param {String} searchTerms + * @param {Object} preFilters + * @param {Object} filters + * @param {Object} filterExplorerSelection + * @param {Boolean} moreData + * @returns {Promise.|Promise} + */ +export function getNodeTypes({ searchTerms, preFilters, filters, filterExplorerSelection, moreData }) { + const postData = { + _token: window.RozierRoot.ajaxToken, + _action: 'toggleExplorer', + search: searchTerms, + page: 1, + } + + if (moreData) { + postData.page = filters ? filters.nextPage : 1 + } + + return request({ + method: 'GET', + url: window.RozierRoot.routes.nodeTypesAjaxExplorer, + params: postData, + }) + .then((response) => { + if (typeof response.data !== 'undefined' && response.data.nodeTypes) { + return { + items: response.data.nodeTypes, + filters: response.data.filters, + } + } else { + return {} + } + }) + .catch((error) => { + // TODO + // Log request error or display a message + throw new Error(error) + }) +} diff --git a/lib/Rozier/src/Resources/app/api/NodesSourceSearchApi.js b/lib/Rozier/src/Resources/app/api/NodesSourceSearchApi.js new file mode 100644 index 00000000..65e57004 --- /dev/null +++ b/lib/Rozier/src/Resources/app/api/NodesSourceSearchApi.js @@ -0,0 +1,31 @@ +import request from 'axios' + +/** + * Fetch Nodes Source from search terms. + * + * @param {String} searchTerms + * @return Promise + */ +export function getNodesSourceFromSearch(searchTerms) { + const postData = { + _token: window.RozierRoot.ajaxToken, + _action: 'searchNodesSources', + searchTerms: searchTerms, + } + + return request({ + method: 'GET', + url: window.RozierRoot.routes.searchNodesSourcesAjax, + params: postData, + }) + .then((response) => { + if (typeof response.data.data !== 'undefined' && response.data.data.length > 0) { + return response.data.data + } else { + return [] + } + }) + .catch((error) => { + throw new Error(error) + }) +} diff --git a/lib/Rozier/src/Resources/app/api/SplashScreenApi.js b/lib/Rozier/src/Resources/app/api/SplashScreenApi.js new file mode 100644 index 00000000..b19da67a --- /dev/null +++ b/lib/Rozier/src/Resources/app/api/SplashScreenApi.js @@ -0,0 +1,23 @@ +import request from 'axios' + +/** + * Get a random image. + * + * @returns {Promise|Promise.} + */ +export function getImage() { + return request({ + method: 'GET', + url: window.RozierRoot.routes.splashRequest, + headers: { 'X-Requested-With': 'XMLHttpRequest' }, + params: { + _: Math.random(), + }, + }) + .then((response) => { + return response.data.url + }) + .catch(() => { + throw new Error('Image not found') + }) +} diff --git a/lib/Rozier/src/Resources/app/api/TagApi.js b/lib/Rozier/src/Resources/app/api/TagApi.js new file mode 100644 index 00000000..0d1aac2a --- /dev/null +++ b/lib/Rozier/src/Resources/app/api/TagApi.js @@ -0,0 +1,116 @@ +import request from 'axios' + +/** + * Fetch all Tags. + * + * @param {String} searchTerms + * @param {Object} filters + * @param filterExplorerSelection + * @param {Boolean} moreData + * @returns {Promise|Promise.} + */ +export function getTags({ searchTerms, filters, filterExplorerSelection, moreData }) { + const postData = { + _token: window.RozierRoot.ajaxToken, + _action: 'getTags', + search: searchTerms, + page: 1, + } + + if (moreData) { + postData.page = filters ? filters.nextPage : 1 + } + + if (filterExplorerSelection && filterExplorerSelection.id) { + postData.tagId = filterExplorerSelection.id + } + + return request({ + method: 'GET', + url: window.RozierRoot.routes.tagsAjaxExplorerList, + params: postData, + }) + .then((response) => { + if (typeof response.data !== 'undefined' && response.data.tags) { + return { + items: response.data.tags, + filters: response.data.filters, + } + } + + throw new Error('No tags found') + }) + .catch((error) => { + if (error.response && error.response.data) { + throw new Error(error.response.data.humanMessage) + } else { + throw new Error(error.message) + } + }) +} + +/** + * Fetch Tags from an array of node id. + * + * @param {Array} ids + * @returns {Promise|Promise.} + */ +export function getTagsByIds({ ids = [] }) { + const postData = { + _token: window.RozierRoot.ajaxToken, + _action: 'documentsByIds', + ids: ids, + } + + return request({ + method: 'GET', + url: window.RozierRoot.routes.tagsAjaxByArray, + params: postData, + }) + .then((response) => { + if (typeof response.data !== 'undefined' && response.data.tags) { + return { + items: response.data.tags, + } + } else { + return {} + } + }) + .catch((error) => { + throw new Error(error.response.data.humanMessage) + }) +} + +/** + * Create a new tag. + * + * @param {String} tagName + * @returns {Promise|Promise.} + */ +export function createTag({ tagName }) { + const postData = { + _token: window.RozierRoot.ajaxToken, + _action: 'documentsByIds', + tagName: tagName, + } + + return request({ + method: 'POST', + url: window.RozierRoot.routes.tagsAjaxCreate, + params: postData, + }) + .then((response) => { + if (typeof response.data !== 'undefined' && response.data.tag) { + return response.data.tag + } + + throw new Error('Tag creation error') + }) + .catch((error) => { + if (error.response && error.response.data) { + throw new Error(error.response.data.humanMessage) + } else { + throw new Error(error.message) + } + }) +} diff --git a/lib/Rozier/src/Resources/app/api/TagExplorerApi.js b/lib/Rozier/src/Resources/app/api/TagExplorerApi.js new file mode 100644 index 00000000..241fb3d9 --- /dev/null +++ b/lib/Rozier/src/Resources/app/api/TagExplorerApi.js @@ -0,0 +1,62 @@ +import request from 'axios' + +/** + * Fetch tags. + * + * @return Promise + */ +export function getTags() { + const postData = { + _token: window.RozierRoot.ajaxToken, + _action: 'tagsExplorer', + } + + return request({ + method: 'GET', + url: window.RozierRoot.routes.tagsAjaxExplorer, + params: postData, + }) + .then((response) => { + if (typeof response.data !== 'undefined' && response.data.tags) { + return { + items: response.data.tags, + } + } else { + return {} + } + }) + .catch((error) => { + throw new Error(error) + }) +} + +/** + * Fetch tags. + * + * @return Promise + */ +export function getParentTags() { + const postData = { + _token: window.RozierRoot.ajaxToken, + _action: 'tagsExplorer', + onlyParents: true, + } + + return request({ + method: 'GET', + url: window.RozierRoot.routes.tagsAjaxExplorer, + params: postData, + }) + .then((response) => { + if (typeof response.data !== 'undefined' && response.data.tags) { + return { + items: response.data.tags, + } + } else { + return {} + } + }) + .catch((error) => { + throw new Error(error) + }) +} diff --git a/lib/Rozier/src/Resources/app/api/index.js b/lib/Rozier/src/Resources/app/api/index.js new file mode 100644 index 00000000..6299301d --- /dev/null +++ b/lib/Rozier/src/Resources/app/api/index.js @@ -0,0 +1,19 @@ +import * as NodesSourceSearchApi from './NodesSourceSearchApi' +import * as ExplorerApi from './ExplorerApi' +import * as FilterExplorerApi from './FilterExplorerApi' +import * as DrawerApi from './DrawerApi' +import * as TagApi from './TagApi' +import * as SplashScreenApi from './SplashScreenApi' +import * as ExplorerProviderApi from './ExplorerProviderApi' + +const api = { + ...NodesSourceSearchApi, + ...ExplorerApi, + ...FilterExplorerApi, + ...DrawerApi, + ...TagApi, + ...SplashScreenApi, + ...ExplorerProviderApi, +} + +export default api diff --git a/lib/Rozier/src/Resources/app/assets/img/apple-touch-icon-114x114.png b/lib/Rozier/src/Resources/app/assets/img/apple-touch-icon-114x114.png new file mode 100644 index 0000000000000000000000000000000000000000..a0742a986501a5b14dfb91e365f1cc731fb9015c GIT binary patch literal 2125 zcmZ`)X*ApW7X2GzuGTeH4XvmtM2wZ>)*PYI8mdt>q$H-GC1`NfH9m@3ik55EQZ0(y zP*PM|b1MWf#n5++jj1)>DsH;g`|{pe@54F!oc&w-ob%oTr2mk<4w2h@B z2Sa~`052zlNA34G!0T&fZw3Gj4~2i=_&B|iw~eDc07R(*KwKgKeCJScD*zA<1%NMJ z0ATV20Hp5}wmKj<58SuyP?o?+%EONxoKEnLjcXVHNP&L_7ur!}i$j7)XnQNrtO(Z` zSpkVL;a42yEZWk{`QFqz%Psx-*twpsbes%7=$e|>t`g(Ctv?%#ELZg?uRWOOn@ZN_ zzeyeSfY`Rk1bP-sRiZ~ugKaaWUS_oUb-g&}ZA(=Vm~E1)?zU8){}(DT?UydDB}R&@ zW3nD)JfE_ENzY<$6Q{6ud@_D>j>7a{cQB7Pm7OZDpbT5Zp5ix|!IIQd%*%9N1uAEbY?j*ayA3Qa@|<#~S!Ei-(z zyk9A3qSa+xIa2I2v!PHmASvc%BZF(|z?LdQK6VNS_PBjUW!i$m>dhH`scu(nc*3^K z9hYqfGOlJ`dZOHyzj@L6VwXHCYeB2t#vS&tQw-e(CPan^aK?0hFO$grB}>Kh)GD$T z+;A6D6oq=ec>`T_P;8adWLq&6U~YKWka?yzDnqqkgQRD#PCF*M{EaGmH{Vu}MPaR{ z&PGV`Dd@6Pq+8#)TIV=0jmNRe@B8JlwM;u$qcV>iR0Er;^oO)HG$Ixv$(;$a(3u06 z24Yap&_$~0;_t+;8hY1;P1QtDkf*4oMt^e}0@o;!0H5YBQeZkb^_C-E!KW8wDhg$p zfvbo6I{lQH0Et4m4~VIX*=lta@~>OPcHSk`x|zc@)XhrC#Yyj4b&Qy^akn*FQ?_1E z>A^Q5>{E3E!6|6p`PN9yudm7Tbz0`rK=kN0-r>*w_b@xtPlS(4B|QbKj7GHonx;;P|5RCZ%#*d;O3JxiB!aEyMwd`JikqDTy6l5^0_bq%&oUdQQQPn>+sXK zPwl4B1Cvj#gU+nor8M&x`eJ2_^U^$9;F|Xo3lw~KuO40>+6pMikZo*v`+-px4k**A zO5kr~pl3I&QTya(U2*t2g_fMfmvjq7csZCT{MQ`(czhErX**{tj6qu zj11#J`XKg?Op09!kqZ+^tl1RXWJioM|} zHNPDzo=#v!hj}TrFv#E$da!*2?+*RS>{RWJ?NC_V%NZXlukba7VuGF1b!E1DU{v2& zMzAR6AS0IGzxH{I>uBJ&+{&lVBx#sPcb#E+NsU;Pf&()ZM}zbD@*BkY6uWW`(YDV` z#I>!IY}6vCP-Jg~7P}<}Ro}hoq!vvp;m3#s@bgXZmc|knn`qSi$1OZuuFG;& zIUeuQ^F}K9${~4Oty1irr`v8kqJf zvqw|%N0R07{`4ede9uu?Im6>CL`UW71m?2q>J`sT^6S&XgrxEB?u>0I>|<+8)c34Q zb5M#;Z~K?MSibQh7uvg#)u%HzF02!?pUItS7w<$ebKKu~MA8@o{=d0iD`K?{#56qB znHzIn(we;8U^ScOczCbeB zTW{ec8=_aYvN-COWuB)N-@{VWmk)6za*ui?^G44&b}Mu&jQR-<43Nx1s3JF z)EaE|3B;~fmwvver5~eqzxz-7NBapb+un2NQ^CCg1+_wQnUs$x#`C+S?ZW=lTXsWV zVmvVwIzKy6z#Me$ONZoOTROQgk zw=`MUmx~vczoWRLL84NUPjqI2u)%$D&HT@>;_e_p+f=O0bk`IoAH}MX|a{z!r zb@d^-FbGuFS=ZPEW?%v})`aSsK%s$T!SMeQ1cmtc6UhHxAg9vM#}UZ>+~5>KAVuOr k@qn36$Zfn5Itb^Bcf{j-$ankjKRq5mTVX8g%)R3O0TtHUl>h($ literal 0 HcmV?d00001 diff --git a/lib/Rozier/src/Resources/app/assets/img/apple-touch-icon-120x120.png b/lib/Rozier/src/Resources/app/assets/img/apple-touch-icon-120x120.png new file mode 100644 index 0000000000000000000000000000000000000000..914125f10613693c58e99b95086bcdd087cf5f45 GIT binary patch literal 2189 zcmZ`*c{J1w7yb>##2{N4*;BF&Gqw=Eh!_SFlaOREBxEpRc-4?KnzFAE5z5*k%V36N zUqX>d_DQnuN%^Kf-|w97o^$VW?sLy`?_c-a8;`Lv<>fxX4FCWy%FNh?g&}|a2nQ>s z`2KNV0f(odr6B;+JcJ(Lz^q)_&CJFU07B&efE)z?`z(sQ002P<09bSZ0NqRg5Fz9? zT+(9|*jz14je*11hrKPVjEi9A5C{OrPyclg%I4G>i-ZtSmL`y|JRm{QBSIgcRV?Nz z%GeMaGQOM}<|BMrIJTuZJ+`7)IxgiC83X6nz#$C8-W@riS-Kt($FXKY#y-aguL;b` zA`8eI*ImpWJ-O&JcoA%3D3h#6;Wh%%H!zK^1#EUVd>emAnN>4z19+xYu*J&iz;_+b zmABtkmh^-B;V{vE+hZ^D^;w?_iV}K_rZ);jbG5)$yWj63RYQ#_Lo@+C8{gG}`%NQ* zgU8qIM7&ZcN*RC&P&Upn%3PX$%iiToSPnKniYxY;N_p<4q@>^pePC!<^3%WeiO6np zfv<*4Kz$4YMw3zU(TrsWtazoLUEg-$QL>NoEg2ubcjiir^>f(_g1SW6Yl_1{FHy~7 zFG@LY8qRsIU+;`Mq6nEAEQ-oDn49C|TXfpemzuDUi^ds zxj@|->DV#*q6ujGtk?1JPZHxOVs4!+eNuXv3&yRm^UHGQDjfA35B*?w#kTW_kPdOS zd|3JVirX=7AE|BZAZ@#MCdUpsCOp(RK>brXNoOSkIL_DR44k6=#*^4 z@@&-KLvCHBen-B?Fq=GfuIkOHWn-M4He)v{!a)3TXl>Vz)s)Lc!`5*`CsFqe&;w^E zcMC1Z$x3RP4_scr9AW1Xdav7gbH_EMg22f0dD898J1nq`jtUTsxO4Hfs67acFGh<-o0ccHrTk|PNMSpv!n(Hcv1G%kyc5XBzZ?!d*#2|1D3;rwAucSR)d zOXs9fDrTlcgC^%JB6ZD6uebQ*OY!#Mu{UhzfP^xB-oPxwbcBjmtJY91!3Ee+H_fFG zgxqGBbwWBuQK2VNfHBZs+_6GjoT(@bQs1~27M>ZS1*xqWUe5Hjg)C8%G9abE5x1}UIg}lZ}n2$1%UPqBlzpZB0!~I{P0$G z*t+Iqzgyju7%ukhV~ZA=Kum+doz~vz(uIk9ubQmapp~hU^i6&Sm3K^)kjiheErp23u$6bk;HU=pUu6GI|$BjYk ziF2G8UQJ17h5vzELht)u7c)ZRix!4yp3al{3!whOC`q`g4nIC=sPw(M_|j}$JNn9v zd*hYW*-&9vB@uq)4qj!u&`-o;NT_p$E040t_{U~`$y$S;C?q-}_!$(Ee>BaY616pd zO!7)=#m=Uh<>Zjs!!ngsfkO|zmV{;nG}G;((#}@Jl<8{QNPT*9+Eu3jC%P+mvT{zT zx#4%Ti7~Z>f0XM>R@q!Fb?$uX=ubn2exb5QNT0k8cAIYNiZO$;F?d_RG|}R z9^m78UOV@fNX~}$aMUj9d==s$oHbt2P|icE*^^Z9|nx&3XzuZ?ao@ru^6<`S<+ar(Lg&%vXXVKTN(_apMNs;Cjo_hLhm z9Zi3Hph`1hO(j&9th+%Wu>)yTrTT=o+frRnLG2vDhPMr&nV-#nU7BdmfTJKh(TRaw z>lwex@5O$L?6#|hp-kYYd{4& zTo7tibQ%UD`%=pXOOmVves4`^;Ang_n@M(XF-j!NVoAo_?g2qFVW#;##pZN0p&FT3UE=4!v8s@-j$=9|M6SeN%S(ZrtUjw9lA-2(6|0Mrmj z4LDK_jzD6O+PZ3*x(ID01X34)@FsBu{Y&8M=kDb}`u~Cxr)s)b0@1${Z2dfl!MFfC jVCe4WikC+D;ym#-c$_=QzYG8OM*vVJR>qY^E|LEQm(JrZ literal 0 HcmV?d00001 diff --git a/lib/Rozier/src/Resources/app/assets/img/apple-touch-icon-144x144.png b/lib/Rozier/src/Resources/app/assets/img/apple-touch-icon-144x144.png new file mode 100644 index 0000000000000000000000000000000000000000..8d1d92b1ffd09acbd5b56bc61de71e0be82fd720 GIT binary patch literal 2607 zcmZ`*c{tQ-8~%+YllAD(VXP_AXc%NjViYp=#*ng{7-N}eY=d+#MktO*!x3c}#aObB z-9(I%;S?h!CZ(~AHA_k4OxOAAyT0%H<9YA*x$o|y(3hg4?_Ot_T~Ukmm#*}EzIv#vDRpN0Ep250MZQr*ydBDIRGHS0f2WI z0E}}1;J1+bZRbt+f`E_hISXJfC4=>Z-|Y{vz8DSw2i1QT2!%ef$|pr4Q1+H09C6SA zdGN2JVl@B&-bGoMW1`2GCSz+|T;!j9{=2n{%M}oy&Vgim#jz(gGYRv#_NnEMUgsK1 z=H=ZxvQN~;HRlHwdnkZP&CZ=R>V-2T0`3GtXNvO8^#?y$il0tU)lm!4nXrvVI=?tM zL0@_MJ!F}f8A3G}?_Jyquavsb( zfgiSORCpD1A&UgDyYSMZt{jHAM9BWy5qw9TL+3P2CRK!!o5J?VXN7#vtEyw2Sr=W0 zjk@icX8TI<0;{oTynlnN7vf#v@evPu>m}irk#sk|rj(AV@=KY<>NriuPB94^I{jp< z5;v&lG3&Q=7?b_k=|YlmmaExs(Y-P~qN3#&UMqvT3#_hBaAQi-X`KS(#EBVYk+OFg z(Ee*UHNbh@;NW%iultQ`3&TBV~ zMO7g??A8PERH7nsBeX$BBb{~_X%N?!BHw|LtP>{D3fqG7CE_E&=6SE;gwBArMVdF3wVa$34*LwawSro1k$hM)Uk_;$SWlSNDv+&a=iB{R51(Ua>5>LTw-hTBmCDvjeu76b<*Lmwo|HRZGDFxn@tFI5{> zXpl^|hdx=yNNZwJx!cwh44oL@WOHm|l$jdM?hv=8{Qb$y+TGjSgvjZ38`EM6V7Z!i ziZ0{@XGMD^&K47MgtHf9Mns&>ly4S+mko8c$m{oIOS-i60IvAi=AI8KRa5E(0PJjEM^7?2hm zqrNG|si7xb+kk1cs-Pq6K0da&P1%iX(GeNH=wlB9sm=NvrF>g@A6}W>onzHTqdX8G zY6rz0t36wTuYe(8+j&keP_o@vWw9pqTeowRuAWeBaN{fAIPPa$zuAmqXxu&1#{N%J zf7$KJcT+o1@ZloHenP-;&Nl#sD@nq2LSDd`5RoAKCNnYpEXW@+5LfSZSwBR#9uQ!59pH7F)9npOJcw7|ZLN{_M_ zm6u4obQtl6DOtXtqHE2nxn(G?Q4j5+LyKzB#i28+HV3-&Q%+q;x-vmod@(y+;m2O5 zq1%_GcbJL6^(|eRBg2FKuJ*`XJ55=OU+PAcIDZBPH_Q*|8ZL>aji%?D_0#z8GneKd5)YSmwgS*y=>+nhIS-X_JgSFkG}?mI82aybl~?{;iJ)DgpzMQmvnm;*Pk zLVV!#T~Nlq%)@Jbw`IYhMjU3~*J6mkjc9KSyY=bph$Y6E6nW$Pu4$ioVtVDyiQQ^G zaWfz+UtL%Go8Q~XW(y3K)OBS!Rr-vR-&feNQN^M+-WbK{;f-ylLC~2FI5dp$7e}tr z99h>xllz39H1llrm`*w$nxj7=?`N=IQ_V|G_OwUvWk_&23K~KAO;TYp>5?6{z$C{ovZC?oPtB!z#8qXU@fMu}!ErD-IRfuE#zRmWf*V{(vwnI>bGxk=ZCRfSk-B+k>2=(Wamf@#z zf4qx|+}mp!_@DXW-^77AyDjDY?r89e{n>Z?Y@{Ax=@#MZ9f32(hT-@C=)rYQz;yLs za9xb9k+GhEG2BQCu4@d3U%9rQ_#cK~f^VSTwf}E`9I5N)8{~g(a3=UgM0tnd0CQi0 f4^9;o?Cp<30nWP<}&#o(TUi1 literal 0 HcmV?d00001 diff --git a/lib/Rozier/src/Resources/app/assets/img/apple-touch-icon-152x152.png b/lib/Rozier/src/Resources/app/assets/img/apple-touch-icon-152x152.png new file mode 100644 index 0000000000000000000000000000000000000000..363df791cf7d4bbfa838e9e61104ce05e8ae1d83 GIT binary patch literal 2736 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 literal 0 HcmV?d00001 diff --git a/lib/Rozier/src/Resources/app/assets/img/apple-touch-icon-180x180.png b/lib/Rozier/src/Resources/app/assets/img/apple-touch-icon-180x180.png new file mode 100644 index 0000000000000000000000000000000000000000..d55df84065feb590d9752d64a288b23be84bc9a6 GIT binary patch literal 3208 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 literal 0 HcmV?d00001 diff --git a/lib/Rozier/src/Resources/app/assets/img/apple-touch-icon-57x57.png b/lib/Rozier/src/Resources/app/assets/img/apple-touch-icon-57x57.png new file mode 100644 index 0000000000000000000000000000000000000000..90bf6f5ee182e4241992e1543130378d5534af70 GIT binary patch literal 1220 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 literal 0 HcmV?d00001 diff --git a/lib/Rozier/src/Resources/app/assets/img/apple-touch-icon-76x76.png b/lib/Rozier/src/Resources/app/assets/img/apple-touch-icon-76x76.png new file mode 100644 index 0000000000000000000000000000000000000000..6074a9d37671c7b25215cb1fade8aece7550f0f9 GIT binary patch literal 1543 zcmZ`(dpy$%9Q_%2jMO~m5z4DX+ceE)affBQ)?}V5X&bX7X*OL-nGsgx)iPq1ywb}f z%5Gg&nmn7dqw>d24YnQ5X0Zk7x%wn^1z`9ex4Hn z#X|Q$Cd106?TofdoJ85!PeRt}p&skm$)HI`(9r#dCyoK@iZg{1)Up zTt`&nt&JygrA)S0(!rQ>>1<-UT6FU%%zIx>*0yCDurNy2VUraOntF-#RT^3rm_S;= zfF@Wu2me2Pz*`@%5DW7= zph5lmU!dSkVSisCMZr+n=(6^b9dR)zz?JHa{tkIFPSn}{h1O8oQ5gZgYx$ORQQS$0 z{5t9P;Wg?=F<$&_)zrL;BX@QXWDobkSTZ4DneEqVPs)G7CcE=bh?kwm-?a9(A$8lnXs4B zZ?LF-L-$S(LQ`E*{3{z_9bgFSayA-k(3g>Tn&ZY5HAW}nSsTpw%_Cp#)M&=g_RAGZ z!hQZysCDx?tY4>90fW=BU5{o9Ik}QmW0&`-!3T&epIZ2l>0N;r#THq*r=3HS24Mj{ z-DkW1z+XMk>ReWGE8o$ju9M#OtTGiymFs?PF!@-u8G9p|2+v;hv_`sU^4y_!W#_W8#;tS=T?OnoF4T-KS_y>4KX)crlYCKTcUPtL)gn@c_a0j~tHgk9l0W?*z| zE2ZZ--$`7z!75-Z8+#k0_?Mo1_u(o$XU2_T_Q8hgZA}HY8~2pSlbomAgialpr(Rg{ zr}|)tAjFzCj3v|-xFe;CuFRKZao5`4s0e){O>HQ~X=8xehmWeCXV1llbED~8CMGJ8 zsS>~yjzGE~TwUOZV1zry)eQr8-wa1!;PAKLyDu1ER literal 0 HcmV?d00001 diff --git a/lib/Rozier/src/Resources/app/assets/img/apple-touch-icon-precomposed.png b/lib/Rozier/src/Resources/app/assets/img/apple-touch-icon-precomposed.png new file mode 100644 index 0000000000000000000000000000000000000000..9fb65ed709276cd6eb7169e145433238fbcb5eae GIT binary patch literal 3153 zcmaKuX*kqv8^-@0vJ68M#>kQ-(O^c1DXL*?*`tgtV+>N37+ESLlF824%~;BoL}VQ# z3^fyDNtTIhg|fyY^5~t8J=?Gf#b{N;vmnBslcM3nn1H)yTB5vy=gMOfsi`_Ym?x4Y*L zr;s4F6-3fIo>OWkRS*z3f(9`>YG)WNto8GPBbd8{YIitJ7Q_#$q3dsQsCISbOn$Z8 zVYN&lWN&?~jM+cyWnnP25;4NP=>t(;~CN5Gc&?@BL8nL1P( z&338;xf3zhpe`j2r>8M_%Hn;gz3yV0rBlmHf7pAAMfLH{z%PhoQga|e`Q_xjfMenl z2O2_Ve8%vM)Ga}MA?;XQ+1o*)(84G|0S|>lu3J~ zog}ARCX*b$Zz(l1;Y&XPB{pT}RK9sWoOx37HHKl`+e{dQ!%@#9t$W z<00u1su*twiiGS=e3q%InJ}MKITBlvsYc+qdV1o|N3(uLe3|gekIJ;}e_Gm6!_$E;@U1*xVN~n_abx#rPhuE5^kVOlj4XxJfmu`Mdy-6YsvDKc3jiFFdpk0kD z%WwJAP2OoCv7z{bS&S6z)Qsao!w1`1npwEMzBH2i_DnYWB(6q+iO9un7q&vrUXS#Oa z_Ay>?r;lYBi&>r$K^2LB{eWn5naavwX2fRCf~2 zS3h?{OBg`Iq=HhasL0Kr;HJK5&$&tLH);m~ScI1HlDR&6_}Qi%x&khxC(pt-+jx!_ zm_NN5X6?EYEh53ImR4EtYBv9Z#NL+BZ)iQmcej(Hx5?8_q_3PPuX8x(&ifHQZU|kZ`?Tg z{cX3PoL=U7N}pdgHYq~c1+1r;8rp&*qP}sLZusBFBIZWoxmPBG%8TOkAZ2UasXlTo zu5>*U(AlkdZPM<7>}yR*WYz{J7Ixih|94`?R61?C#0Hx9_y#lagNmPu43{pPlzBdvS zbMo&;d+m?-HN3TA^l5r8hT@ghCd67&U0EXk(1)KjWrbgy_RT3ziuWs?xD|g(&{bYc z#QXcSKweYST2KA%se#9P`p3181qd1>-1$U|rACUQvTQ1%%TI*!+rRm>k5BlyCuDzy zHZwCb8j9OlUp^e|2Fth8GR>JbTceISi5x1a?#e$#u%-|q}V7rr-G+;nAA!) zDdRG-=ok~`p@dF2N=Pd=|(|6F}jdH>u=2kkW!G_prvM$T-n8j|S=hBCU6RVDkA~d5c#%NxJdtUik;6soVV-=qWug?@- zJ#+BuRvl6B%lTehzwsdp26Juln|>lM{b|1Fc5O9IMFuJJK_jo@DR*UJwTfr(rlm<@iO4r z5HWhkJ)fwy1*h~%CF0L^jJ|ZsD;$oo*b+iGC4jqo@`Xw{L^gsNQ)k6xu%?Z zPc=flk5g+wIU?`w$-gfQnHTDkp1C_R>YZGKLm$LXpavgtnqFS~tJ5M}~LQiec6gv`$q64vj&G+u%cmB{BXEoxU!z}>Bshz-WFrtt&t2z|-?Z~gL_W39Y*x_R^2{gpF%D-@fF_N{hP7OM zszq2khU$jFsu(*eZ4TG*&C_WjWVGnZ6<5NDOzNxa5Vdn>ld8GM6R_>dQ-t!Aeg>uNyOwBMgg4lx6 z=b;Do=o&PczrcPszTd7=t(umzgW`-7&ZI;gAMC7CxXz9GHc(OQ=;6q)$RQ~+$eyyA zHIIyAGutGdz0@7eN4!UQx5eFwSx|C2>eULt)g6XzflB%4R)iA85odv)BQNKNl|hdlEvhfq3Ae hhrc^s*3=j4g}23HJ;H9i#sBH~fGOJ2uo~qS{U7CT=c)hz literal 0 HcmV?d00001 diff --git a/lib/Rozier/src/Resources/app/assets/img/apple-touch-icon.png b/lib/Rozier/src/Resources/app/assets/img/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..d55df84065feb590d9752d64a288b23be84bc9a6 GIT binary patch literal 3208 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 literal 0 HcmV?d00001 diff --git a/lib/Rozier/src/Resources/app/assets/img/browserconfig.xml b/lib/Rozier/src/Resources/app/assets/img/browserconfig.xml new file mode 100644 index 00000000..fe44cae8 --- /dev/null +++ b/lib/Rozier/src/Resources/app/assets/img/browserconfig.xml @@ -0,0 +1,12 @@ + + + + + + + + + #da532c + + + diff --git a/lib/Rozier/src/Resources/app/assets/img/default_login.jpg b/lib/Rozier/src/Resources/app/assets/img/default_login.jpg new file mode 100644 index 0000000000000000000000000000000000000000..24b3f3f371a7c62314ccc0f825ff56629030f4b7 GIT binary patch literal 343413 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

2M@^RfiJ)Hc);6;)h0e8`dk;lx`P2P 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 z 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�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 z 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 HeC_|g03R5_ literal 0 HcmV?d00001 diff --git a/lib/Rozier/src/Resources/app/assets/img/favicon-160x160.png b/lib/Rozier/src/Resources/app/assets/img/favicon-160x160.png new file mode 100644 index 0000000000000000000000000000000000000000..e33f50a501ad91770c74c2b989208a5cb48affad GIT binary patch literal 2847 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 literal 0 HcmV?d00001 diff --git a/lib/Rozier/src/Resources/app/assets/img/favicon.ico b/lib/Rozier/src/Resources/app/assets/img/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..d88ec0cb576c0abac9cae8a5c0f66058de3d7554 GIT binary patch literal 15086 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 literal 0 HcmV?d00001 diff --git a/lib/Rozier/src/Resources/app/assets/img/jqueryui/ui-bg_diagonals-thick_18_b81900_40x40.png b/lib/Rozier/src/Resources/app/assets/img/jqueryui/ui-bg_diagonals-thick_18_b81900_40x40.png new file mode 100644 index 0000000000000000000000000000000000000000..086ee268d236286ca7322b6fb86fd774e9cdd67d GIT binary patch literal 223 zcmeAS@N?(olHy`uVBq!ia0vp^8Xzpd1SErbK34*%Wu7jMAsP4H-cS^3P!Mo?xVP2W zL~4)y+31I%;&60Tzt|_;BpEL9S-t4*+p&PWeGF)4h zmcW07<(soC%X#m;-z^rEHJGmOO;CNs9nl}fx;ltEAvcTdn}ctH{T2QL#&Ma?+O0U| z{rzTUe}!+urT$YHQ6{>x#C*G#xn5qBd2tISckM~P>D+hs#Vrl+o!AR>F@vY8pUXO@ GgeCwDgjj6= literal 0 HcmV?d00001 diff --git a/lib/Rozier/src/Resources/app/assets/img/jqueryui/ui-bg_diagonals-thick_20_666666_40x40.png b/lib/Rozier/src/Resources/app/assets/img/jqueryui/ui-bg_diagonals-thick_20_666666_40x40.png new file mode 100644 index 0000000000000000000000000000000000000000..b0336374edf2d04a3236f56211fae4d08c0fc33c GIT binary patch literal 171 zcmeAS@N?(olHy`uVBq!ia0vp^8XznHBp80OT7LpkMV>B>As)xyUfn2oK!Jzl;QDXY z3j#x*uq5{wcIt_6C7KB@7yJIBc;4>HbMKE7F)!J{R}{m&5s2=vohjTFk;nM?4!6n< z&XgUDK{4!>=fqCmOxh`Lu-P_gC-02Axf|~=3EB6r(!4q)%gc1>tRUaUe@D4*EnlQv SR9N^7zopr09#*GS8aVKUX{peRQsXEt6;(0wcO4y@5`>QfB)(*6v!Me ztUI?`s_nR+*``bDlpZhe^RaxT`7A73wP@0!6S}P$T(d589g>PYGD&2gZ=jAv>)M>O tt=E*2^XJak5U6=%#;NJneV^agFvz@>Znd59Y(LOV44$rjF6*2UngBENO=tiB literal 0 HcmV?d00001 diff --git a/lib/Rozier/src/Resources/app/assets/img/jqueryui/ui-bg_glass_65_ffffff_1x400.png b/lib/Rozier/src/Resources/app/assets/img/jqueryui/ui-bg_glass_65_ffffff_1x400.png new file mode 100644 index 0000000000000000000000000000000000000000..0af053771c1fc0f2ce949ec5e341b085a45cc648 GIT binary patch literal 74 zcmeAS@N?(olHy`uVBq!ia0vp^j6gI2NH8$CE1Q=ADM3#c#}JM4$r%X}t&0mp9yKsb W%{ak$*<@oTNRg+jpUXO@geCwKViDW` literal 0 HcmV?d00001 diff --git a/lib/Rozier/src/Resources/app/assets/img/jqueryui/ui-bg_gloss-wave_35_f6a828_500x100.png b/lib/Rozier/src/Resources/app/assets/img/jqueryui/ui-bg_gloss-wave_35_f6a828_500x100.png new file mode 100644 index 0000000000000000000000000000000000000000..ca24203c959d95f6bfb4c92f481194e3fc896db6 GIT binary patch literal 5115 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 xCfn8Eb_yH%hYsgYFa-FlnI|=$B+_6pm3jeH literal 0 HcmV?d00001 diff --git a/lib/Rozier/src/Resources/app/assets/img/jqueryui/ui-bg_highlight-soft_100_eeeeee_1x100.png b/lib/Rozier/src/Resources/app/assets/img/jqueryui/ui-bg_highlight-soft_100_eeeeee_1x100.png new file mode 100644 index 0000000000000000000000000000000000000000..ac095d8ee8cc446bf84856aa66239536564522ad GIT binary patch literal 125 zcmeAS@N?(olHy`uVBq!ia0vp^j6j?s03;ZUuHXC*q+C2*977~7C;wb|_Rs+b@h|h9 z{?8I?3sv}1ug2}He)>P3SR3E8|4(&zp6dKReL$sUE}QUshShHvvRRDEHvh5r*1w+Q Z$S`q|bnd_Ff46|l^>p=fS?83{1OVoWE&c!i literal 0 HcmV?d00001 diff --git a/lib/Rozier/src/Resources/app/assets/img/jqueryui/ui-bg_highlight-soft_75_ffe45c_1x100.png b/lib/Rozier/src/Resources/app/assets/img/jqueryui/ui-bg_highlight-soft_75_ffe45c_1x100.png new file mode 100644 index 0000000000000000000000000000000000000000..43341bf8318be58563cf1f87e13d3af0da0c4328 GIT binary patch literal 167 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 literal 0 HcmV?d00001 diff --git a/lib/Rozier/src/Resources/app/assets/img/jqueryui/ui-icons_228ef1_256x240.png b/lib/Rozier/src/Resources/app/assets/img/jqueryui/ui-icons_228ef1_256x240.png new file mode 100644 index 0000000000000000000000000000000000000000..30ad15e65e4c2d14fbcf4f6a4f3c439d1ed67018 GIT binary patch literal 4261 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 MOl*uR3<>f713RV!)Bpeg literal 0 HcmV?d00001 diff --git a/lib/Rozier/src/Resources/app/assets/img/jqueryui/ui-icons_ef8c08_256x240.png b/lib/Rozier/src/Resources/app/assets/img/jqueryui/ui-icons_ef8c08_256x240.png new file mode 100644 index 0000000000000000000000000000000000000000..7fe5bb7635745e6a88b173f39c1a7c7dbc63ed95 GIT binary patch literal 4261 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 Mg^7)ExgjzBe<)%4b^rhX literal 0 HcmV?d00001 diff --git a/lib/Rozier/src/Resources/app/assets/img/jqueryui/ui-icons_ffd27a_256x240.png b/lib/Rozier/src/Resources/app/assets/img/jqueryui/ui-icons_ffd27a_256x240.png new file mode 100644 index 0000000000000000000000000000000000000000..815732c445bd883a63ccca6621248d2db28aebf0 GIT binary patch literal 4261 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 literal 0 HcmV?d00001 diff --git a/lib/Rozier/src/Resources/app/assets/img/marker@2x.png b/lib/Rozier/src/Resources/app/assets/img/marker@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..95c77739049c90ed0ad003b4d108282118993a07 GIT binary patch literal 2445 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 literal 0 HcmV?d00001 diff --git a/lib/Rozier/src/Resources/app/assets/img/matrix@2x.png b/lib/Rozier/src/Resources/app/assets/img/matrix@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..b856cf4f20fb1cc37462fcb325096bed9001cd3c GIT binary patch literal 1341 zcmeAS@N?(olHy`uVBq!ia0y~yV7bA-z^uT*3=}!Wr@sYASqAuoxc>kDe+o9Rdi83E zFhs$fJ9mKGj*bo>36uf~D!km81vG@eB*-tAK|sO4Az{LT1se{Wxp4DCrpPa#49gNv z7srr_TW@b3%xek|alP0ccC^Xnh|NM9B+m59jICnVg z-1H9_4{8uv)TIDSFG%BvUL1Z wnQG#2gQ0~NMqqc>sOV@2jD`TML*QShdL*aVvzuGbKLJ_g>FVdQ&MBb@0P%Uk2mk;8 literal 0 HcmV?d00001 diff --git a/lib/Rozier/src/Resources/app/assets/img/mstile-144x144.png b/lib/Rozier/src/Resources/app/assets/img/mstile-144x144.png new file mode 100644 index 0000000000000000000000000000000000000000..97bd51763790c8f91995b7c17392da51d20d9d1a GIT binary patch literal 2499 zcmZ`*Sv=H@_x_sf%g@#rB557QHe)x0u}`uLgR&21N+iZuvNnu0*^{gjZwPruqM5vs zt+MYClh6<%B^ui=KVAGU-i!amInU=g=R6nZ=J`CSb~YA5{3rMU01!f3n%W;>)}P|# zJnGiAF!dwgyoRzy0YF2hzyXfyh|Bp{+FJua)&Wqkpsjd0SbS0*A?&{cT5#hlI8^AQHmhTXHEQ1=b+)`Eg0OU}4U~bMMY57Tte*IE6h>vm3dT9xUT6 zX~sj-%EhL02p=;&!B-~|m6Q{oid`vxt5bv{_Mk3^7B zMYwL;EO-xm72h&Uqi5hy?lfJ}0p35OjOyUlBU9?+%xJX?k>GVv&+oob{cNXNH+M-{ zQA3hggcZXOG&ySO-1!eH* zS2Ou%MMx~fHrI@eYmQNnl%Yeu!$kgh5y2%CzT!HYPuW^-N9%0B%aD98Gh&VqMf9*B znqR(LykupV^=|qn)#d5o$7rrzS}A&bsm^pU+k&H;vo!}iQ^(~)- zyR~D!#~J@BWYrw>G*p?eG0TwH59 zv>l)#oEu|h9%sh9U-Bq+RB($q@=OR;G!5%ZE2SQuZ&b01(4Xp5el9BYAP64a*A+!e z{N@JLQ1SMotA>J5v;?aaRkeX(XoMb9fqjyY081J}%MZKGe{ztwD-dc%U<+Q5sQxs4 zjq8_q+1&2C+b3ZG(dm#Y?(66Oc7oK$n&iQ)+2>7?Y3x zSstTbBV2ak`EEy-p+@>@z@Fg1ha2b#XbSfp0sla4jpu zgikL+7QW$|pm;{#_q}tr4IXRI&Y-;cCjSU?*XyOA7q@=CK zdurHahXJv2nZQ7n1>&@4N`Baag`t z8bTa+d@xw4Hwo^Rz3iZSY6#hvdoZxKJ^NWy`D3d1{43VpcJ)aI_imXrSpizvsa5N+ z&Tv&bS6RCU=5@+TnG%0v`+Ss|PR5Fv?fWyd!%Hxy2;2=bN`{r>+x=tjh4s+{XCIKZ zY280P#){7GHkC40r)DVEa?_0<4)w%eSC_d<<$jy!_8G&r9!U5MXz(ks=3Kciw5)^zoYZphAI zS{@Enp^2-vqgnO)o)NFmvXh9?-M}GrD|_l-UTv`n=N@Oshk)gE9Ra$v^GgF9;say1 zNU|&nb(qUos7KuK!NM`OLQWB%fttK?_T0LrfBq9V_arM<60I9{1ApigX literal 0 HcmV?d00001 diff --git a/lib/Rozier/src/Resources/app/assets/img/mstile-150x150.png b/lib/Rozier/src/Resources/app/assets/img/mstile-150x150.png new file mode 100644 index 0000000000000000000000000000000000000000..9fc6336fc77ce7aa7d8515d2e9de384e07d71b1f GIT binary patch literal 2551 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 literal 0 HcmV?d00001 diff --git a/lib/Rozier/src/Resources/app/assets/img/mstile-70x70.png b/lib/Rozier/src/Resources/app/assets/img/mstile-70x70.png new file mode 100644 index 0000000000000000000000000000000000000000..9c98673d0e21733f26abcda64db57b15f82a5431 GIT binary patch literal 1777 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 Bm{9-# literal 0 HcmV?d00001 diff --git a/lib/Rozier/src/Resources/app/assets/img/spritemap@2x.png b/lib/Rozier/src/Resources/app/assets/img/spritemap@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..ed29b88b6e2154cb6d1b88ab5bf9a3b7eac87d0c GIT binary patch literal 35675 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 literal 0 HcmV?d00001 diff --git a/lib/Rozier/src/Resources/app/components/AjaxLink.vue b/lib/Rozier/src/Resources/app/components/AjaxLink.vue new file mode 100644 index 00000000..0114a0b0 --- /dev/null +++ b/lib/Rozier/src/Resources/app/components/AjaxLink.vue @@ -0,0 +1,21 @@ + + + diff --git a/lib/Rozier/src/Resources/app/components/BlanchetteToolbar.vue b/lib/Rozier/src/Resources/app/components/BlanchetteToolbar.vue new file mode 100644 index 00000000..e840334e --- /dev/null +++ b/lib/Rozier/src/Resources/app/components/BlanchetteToolbar.vue @@ -0,0 +1,274 @@ + + + diff --git a/lib/Rozier/src/Resources/app/components/CodeMirror.vue b/lib/Rozier/src/Resources/app/components/CodeMirror.vue new file mode 100644 index 00000000..3c0926c7 --- /dev/null +++ b/lib/Rozier/src/Resources/app/components/CodeMirror.vue @@ -0,0 +1,85 @@ + + diff --git a/lib/Rozier/src/Resources/app/components/CustomFormPreviewItem.vue b/lib/Rozier/src/Resources/app/components/CustomFormPreviewItem.vue new file mode 100644 index 00000000..3d6d669b --- /dev/null +++ b/lib/Rozier/src/Resources/app/components/CustomFormPreviewItem.vue @@ -0,0 +1,31 @@ + + diff --git a/lib/Rozier/src/Resources/app/components/DocumentPreviewListItem.vue b/lib/Rozier/src/Resources/app/components/DocumentPreviewListItem.vue new file mode 100644 index 00000000..7a308ff3 --- /dev/null +++ b/lib/Rozier/src/Resources/app/components/DocumentPreviewListItem.vue @@ -0,0 +1,144 @@ + + + diff --git a/lib/Rozier/src/Resources/app/components/DrawerItem.vue b/lib/Rozier/src/Resources/app/components/DrawerItem.vue new file mode 100644 index 00000000..0b642fbd --- /dev/null +++ b/lib/Rozier/src/Resources/app/components/DrawerItem.vue @@ -0,0 +1,132 @@ + + diff --git a/lib/Rozier/src/Resources/app/components/Dropzone.vue b/lib/Rozier/src/Resources/app/components/Dropzone.vue new file mode 100644 index 00000000..d6b06dc3 --- /dev/null +++ b/lib/Rozier/src/Resources/app/components/Dropzone.vue @@ -0,0 +1,221 @@ + + + diff --git a/lib/Rozier/src/Resources/app/components/ExplorerItemsInfos.vue b/lib/Rozier/src/Resources/app/components/ExplorerItemsInfos.vue new file mode 100644 index 00000000..6ca0f029 --- /dev/null +++ b/lib/Rozier/src/Resources/app/components/ExplorerItemsInfos.vue @@ -0,0 +1,22 @@ + + diff --git a/lib/Rozier/src/Resources/app/components/FilterExplorerButton.vue b/lib/Rozier/src/Resources/app/components/FilterExplorerButton.vue new file mode 100644 index 00000000..15742722 --- /dev/null +++ b/lib/Rozier/src/Resources/app/components/FilterExplorerButton.vue @@ -0,0 +1,33 @@ + + diff --git a/lib/Rozier/src/Resources/app/components/FilterExplorerItem.vue b/lib/Rozier/src/Resources/app/components/FilterExplorerItem.vue new file mode 100644 index 00000000..512d64bc --- /dev/null +++ b/lib/Rozier/src/Resources/app/components/FilterExplorerItem.vue @@ -0,0 +1,59 @@ + + + diff --git a/lib/Rozier/src/Resources/app/components/JoinPreviewItem.vue b/lib/Rozier/src/Resources/app/components/JoinPreviewItem.vue new file mode 100644 index 00000000..fe8e2031 --- /dev/null +++ b/lib/Rozier/src/Resources/app/components/JoinPreviewItem.vue @@ -0,0 +1,30 @@ + + diff --git a/lib/Rozier/src/Resources/app/components/LoadMoreButton.vue b/lib/Rozier/src/Resources/app/components/LoadMoreButton.vue new file mode 100644 index 00000000..1c56c7f6 --- /dev/null +++ b/lib/Rozier/src/Resources/app/components/LoadMoreButton.vue @@ -0,0 +1,36 @@ + + diff --git a/lib/Rozier/src/Resources/app/components/NodePreviewItem.vue b/lib/Rozier/src/Resources/app/components/NodePreviewItem.vue new file mode 100644 index 00000000..06fd4f54 --- /dev/null +++ b/lib/Rozier/src/Resources/app/components/NodePreviewItem.vue @@ -0,0 +1,32 @@ + + diff --git a/lib/Rozier/src/Resources/app/components/NodeTypePreviewItem.vue b/lib/Rozier/src/Resources/app/components/NodeTypePreviewItem.vue new file mode 100644 index 00000000..ed6dc04d --- /dev/null +++ b/lib/Rozier/src/Resources/app/components/NodeTypePreviewItem.vue @@ -0,0 +1,31 @@ + + diff --git a/lib/Rozier/src/Resources/app/components/Overlay.vue b/lib/Rozier/src/Resources/app/components/Overlay.vue new file mode 100644 index 00000000..62f6459c --- /dev/null +++ b/lib/Rozier/src/Resources/app/components/Overlay.vue @@ -0,0 +1,16 @@ + + diff --git a/lib/Rozier/src/Resources/app/components/RzButton.vue b/lib/Rozier/src/Resources/app/components/RzButton.vue new file mode 100644 index 00000000..f1029c4c --- /dev/null +++ b/lib/Rozier/src/Resources/app/components/RzButton.vue @@ -0,0 +1,14 @@ + + + diff --git a/lib/Rozier/src/Resources/app/components/RzTextarea.vue b/lib/Rozier/src/Resources/app/components/RzTextarea.vue new file mode 100644 index 00000000..ec8ea488 --- /dev/null +++ b/lib/Rozier/src/Resources/app/components/RzTextarea.vue @@ -0,0 +1,26 @@ + + diff --git a/lib/Rozier/src/Resources/app/components/TagCreatorItem.vue b/lib/Rozier/src/Resources/app/components/TagCreatorItem.vue new file mode 100644 index 00000000..fc731040 --- /dev/null +++ b/lib/Rozier/src/Resources/app/components/TagCreatorItem.vue @@ -0,0 +1,120 @@ + + + diff --git a/lib/Rozier/src/Resources/app/components/TagPreviewItem.vue b/lib/Rozier/src/Resources/app/components/TagPreviewItem.vue new file mode 100644 index 00000000..4ef74739 --- /dev/null +++ b/lib/Rozier/src/Resources/app/components/TagPreviewItem.vue @@ -0,0 +1,30 @@ + + diff --git a/lib/Rozier/src/Resources/app/components/WarningModal.vue b/lib/Rozier/src/Resources/app/components/WarningModal.vue new file mode 100644 index 00000000..846dce8e --- /dev/null +++ b/lib/Rozier/src/Resources/app/components/WarningModal.vue @@ -0,0 +1,219 @@ + + + diff --git a/lib/Rozier/src/Resources/app/components/attribute-values/AttributeValuePosition.js b/lib/Rozier/src/Resources/app/components/attribute-values/AttributeValuePosition.js new file mode 100644 index 00000000..bf461450 --- /dev/null +++ b/lib/Rozier/src/Resources/app/components/attribute-values/AttributeValuePosition.js @@ -0,0 +1,86 @@ +import $ from 'jquery' + +export default class AttributeValuePosition { + constructor() { + this.$list = $('.attribute-value-forms > .uk-sortable') + this.currentRequest = null + + // Bind methods + this.onSortableChange = this.onSortableChange.bind(this) + + this.init() + } + + init() { + if (this.$list.length && this.$list.children().length > 1) { + this.$list.on('change.uk.sortable', this.onSortableChange) + } + } + + unbind() { + if (this.$list.length && this.$list.children().length > 1) { + this.$list.off('change.uk.sortable', this.onSortableChange) + } + } + + /** + * @param event + * @param list + * @param element + */ + onSortableChange(event, list, element) { + if (this.currentRequest && this.currentRequest.readyState !== 4) { + this.currentRequest.abort() + } + + if (event.target instanceof HTMLInputElement) { + return + } + + let $element = $(element) + let attributeValueId = parseInt($element.data('id')) + let $sibling = $element.prev() + let newPosition = 0.0 + + if ($sibling.length === 0) { + $sibling = $element.next() + newPosition = parseInt($sibling.data('position')) - 0.5 + } else { + newPosition = parseInt($sibling.data('position')) + 0.5 + } + + let postData = { + _token: window.Rozier.ajaxToken, + _action: 'updatePosition', + attributeValueId: attributeValueId, + newPosition: newPosition, + } + // TODO: entry point + if (window.Rozier.routes.attributeValueAjaxEdit) { + this.currentRequest = $.ajax({ + url: window.Rozier.routes.attributeValueAjaxEdit.replace('%attributeValueId%', attributeValueId), + type: 'POST', + dataType: 'json', + data: postData, + }) + .done((data) => { + $element.attr('data-position', newPosition) + window.UIkit.notify({ + message: data.responseText, + status: data.status, + timeout: 3000, + pos: 'top-center', + }) + }) + .fail((data) => { + data = JSON.parse(data.responseText) + window.UIkit.notify({ + message: data.error_message, + status: 'danger', + timeout: 3000, + pos: 'top-center', + }) + }) + } + } +} diff --git a/lib/Rozier/src/Resources/app/components/bulk-edits/DocumentsBulk.js b/lib/Rozier/src/Resources/app/components/bulk-edits/DocumentsBulk.js new file mode 100644 index 00000000..b5834574 --- /dev/null +++ b/lib/Rozier/src/Resources/app/components/bulk-edits/DocumentsBulk.js @@ -0,0 +1,160 @@ +import $ from 'jquery' + +export default class DocumentsBulk { + /** + * Create a documents bulk + */ + constructor() { + this.$documentsCheckboxes = $('input.document-checkbox') + this.$documentsIdBulkFolders = $('input.document-id-bulk-folder') + this.$actionsMenu = $('.documents-bulk-actions') + this.$documentsFolderButton = $('.uk-button-bulk-folder-documents') + this.$documentsFolderCont = $('.documents-bulk-folder-cont') + this.$documentsSelectAll = $('.uk-button-select-all') + this.$documentsDeselectAll = $('.uk-button-bulk-deselect') + this.$bulkDeleteButton = this.$actionsMenu.find('.uk-button-bulk-delete-documents') + this.$bulkDownloadButton = this.$actionsMenu.find('.uk-button-bulk-download-documents') + this.documentsFolderOpen = false + this.documentsIds = null + + this.onCheckboxChange = this.onCheckboxChange.bind(this) + this.onBulkDelete = this.onBulkDelete.bind(this) + this.documentsFolderButtonClick = this.documentsFolderButtonClick.bind(this) + this.onBulkDownload = this.onBulkDownload.bind(this) + this.onSelectAll = this.onSelectAll.bind(this) + this.onDeselectAll = this.onDeselectAll.bind(this) + + if (this.$documentsCheckboxes.length) { + this.init() + } + } + + init() { + this.$documentsCheckboxes.on('change', this.onCheckboxChange) + this.$bulkDeleteButton.on('click', this.onBulkDelete) + this.$documentsFolderButton.on('click', this.documentsFolderButtonClick) + this.$bulkDownloadButton.on('click', this.onBulkDownload) + this.$documentsSelectAll.on('click', this.onSelectAll) + this.$documentsDeselectAll.on('click', this.onDeselectAll) + } + + unbind() { + if (this.$documentsCheckboxes.length) { + this.$documentsCheckboxes.off('change', this.onCheckboxChange) + this.$bulkDeleteButton.off('click', this.onBulkDelete) + this.$documentsFolderButton.off('click', this.documentsFolderButtonClick) + this.$bulkDownloadButton.off('click', this.onBulkDownload) + this.$documentsSelectAll.off('click', this.onSelectAll) + this.$documentsDeselectAll.off('click', this.onDeselectAll) + } + } + + onSelectAll() { + this.$documentsCheckboxes.prop('checked', true) + this.onCheckboxChange(null) + return false + } + + onDeselectAll() { + this.$documentsCheckboxes.prop('checked', false) + this.onCheckboxChange(null) + return false + } + + /** + * On checkbox change + */ + onCheckboxChange() { + this.documentsIds = [] + + $('input.document-checkbox:checked').each((index, domElement) => { + this.documentsIds.push($(domElement).val()) + }) + + if (this.$documentsIdBulkFolders.length) { + this.$documentsIdBulkFolders.val(this.documentsIds.join(',')) + } + + if (this.documentsIds.length > 0) { + this.showActions() + } else { + this.hideActions() + } + } + + /** + * On bulk delete + * @returns {boolean} + */ + onBulkDelete() { + if (this.documentsIds.length > 0) { + history.pushState( + { + headerData: { + documents: this.documentsIds, + }, + }, + null, + window.Rozier.routes.documentsBulkDeletePage + ) + + window.Rozier.lazyload.onPopState(null) + } + + return false + } + + /** + * On bulk Download + * @returns {boolean} + */ + onBulkDownload() { + if (this.documentsIds.length > 0) { + history.pushState( + { + headerData: { + documents: this.documentsIds, + }, + }, + null, + window.Rozier.routes.documentsBulkDownloadPage + ) + + window.Rozier.lazyload.onPopState(null) + } + + return false + } + + /** + * Show actions + */ + showActions() { + this.$actionsMenu.stop() + this.$actionsMenu.slideDown() + } + + /** + * Hide actions + */ + hideActions() { + this.$actionsMenu.stop() + this.$actionsMenu.slideUp() + } + + /** + * Documents folder button click + * @returns {boolean} + */ + documentsFolderButtonClick() { + if (!this.documentsFolderOpen) { + this.$documentsFolderCont.slideDown() + this.documentsFolderOpen = true + } else { + this.$documentsFolderCont.slideUp() + this.documentsFolderOpen = false + } + + return false + } +} diff --git a/lib/Rozier/src/Resources/app/components/bulk-edits/NodesBulk.js b/lib/Rozier/src/Resources/app/components/bulk-edits/NodesBulk.js new file mode 100644 index 00000000..69d0bd54 --- /dev/null +++ b/lib/Rozier/src/Resources/app/components/bulk-edits/NodesBulk.js @@ -0,0 +1,167 @@ +import $ from 'jquery' + +/** + * Nodes bulk + */ +export default class NodesBulk { + constructor() { + this.$nodesCheckboxes = $('input.node-checkbox') + this.$nodesIdBulkTags = $('input.nodes-id-bulk-tags') + this.$nodesIdBulkStatus = $('input.nodes-id-bulk-status') + this.$actionsMenu = $('.nodes-bulk-actions') + + this.$nodesFolderButton = $('.uk-button-bulk-folder-nodes') + this.$nodesFolderCont = $('.nodes-bulk-folder-cont') + + this.$nodesStatusButton = $('.uk-button-bulk-status-nodes') + this.$nodesStatusCont = $('.nodes-bulk-status-cont') + + this.$nodesSelectAll = $('.uk-button-select-all') + this.$nodesDeselectAll = $('.uk-button-bulk-deselect') + + this.nodesFolderOpen = false + this.nodesStatusOpen = false + this.nodesIds = null + + this.onCheckboxChange = this.onCheckboxChange.bind(this) + this.nodesFolderButtonClick = this.nodesFolderButtonClick.bind(this) + this.nodesStatusButtonClick = this.nodesStatusButtonClick.bind(this) + this.onSelectAll = this.onSelectAll.bind(this) + this.onDeselectAll = this.onDeselectAll.bind(this) + + if (this.$nodesCheckboxes.length) { + this.init() + } + } + + /** + * Init + */ + init() { + this.$nodesCheckboxes.on('change', this.onCheckboxChange) + this.$nodesFolderButton.on('click', this.nodesFolderButtonClick) + this.$nodesStatusButton.on('click', this.nodesStatusButtonClick) + this.$nodesSelectAll.on('click', this.onSelectAll) + this.$nodesDeselectAll.on('click', this.onDeselectAll) + } + + unbind() { + if (this.$nodesCheckboxes.length) { + this.$nodesCheckboxes.off('change', this.onCheckboxChange) + this.$nodesFolderButton.off('click', this.nodesFolderButtonClick) + this.$nodesStatusButton.off('click', this.nodesStatusButtonClick) + this.$nodesSelectAll.off('click', this.onSelectAll) + this.$nodesDeselectAll.off('click', this.onDeselectAll) + } + } + + onSelectAll() { + this.$nodesCheckboxes.prop('checked', true) + this.onCheckboxChange(null) + return false + } + + onDeselectAll() { + this.$nodesCheckboxes.prop('checked', false) + this.onCheckboxChange(null) + return false + } + + /** + * On checkbox change + */ + onCheckboxChange() { + this.nodesIds = [] + + $('input.node-checkbox:checked').each((index, domElement) => { + this.nodesIds.push($(domElement).val()) + }) + + if (this.$nodesIdBulkTags.length) { + this.$nodesIdBulkTags.val(this.nodesIds.join(',')) + } + + if (this.$nodesIdBulkStatus.length) { + this.$nodesIdBulkStatus.val(this.nodesIds.join(',')) + } + + if (this.nodesIds.length > 0) { + this.showActions() + } else { + this.hideActions() + } + + return false + } + + /** + * On bulk delete + */ + onBulkDelete() { + if (this.nodesIds.length > 0) { + history.pushState( + { + headerData: { + nodes: this.nodesIds, + }, + }, + null, + window.Rozier.routes.nodesBulkDeletePage + ) + + window.Rozier.lazyload.onPopState(null) + } + + return false + } + + /** + * Show actions + */ + showActions() { + this.$actionsMenu.slideDown() + } + + /** + * Hide actions + */ + hideActions() { + this.$actionsMenu.slideUp() + } + + /** + * Nodes folder button click + */ + nodesFolderButtonClick() { + if (!this.nodesFolderOpen) { + this.$nodesStatusCont.slideUp() + this.nodesStatusOpen = false + + this.$nodesFolderCont.slideDown() + this.nodesFolderOpen = true + } else { + this.$nodesFolderCont.slideUp() + this.nodesFolderOpen = false + } + + return false + } + + /** + * Nodes status button click + */ + nodesStatusButtonClick() { + if (!this.nodesStatusOpen) { + this.$nodesFolderCont.slideUp() + this.nodesFolderOpen = false + + this.$nodesStatusCont.slideDown() + this.nodesStatusOpen = true + } else { + this.$nodesStatusCont.slideUp() + this.nodesStatusOpen = false + } + + return false + } +} diff --git a/lib/Rozier/src/Resources/app/components/bulk-edits/TagsBulk.js b/lib/Rozier/src/Resources/app/components/bulk-edits/TagsBulk.js new file mode 100644 index 00000000..04b2d555 --- /dev/null +++ b/lib/Rozier/src/Resources/app/components/bulk-edits/TagsBulk.js @@ -0,0 +1,172 @@ +import $ from 'jquery' + +/** + * Tags bulk + */ +export default class TagsBulk { + /** + * Create Tags bulk + */ + constructor() { + this.$tagsCheckboxes = $('input.tag-checkbox') + this.$tagsIdBulkTags = $('input.tags-id-bulk-tags') + this.$tagsIdBulkStatus = $('input.tags-id-bulk-status') + this.$actionsMenu = $('.tags-bulk-actions') + + this.$tagsFolderButton = $('.uk-button-bulk-folder-tags') + this.$tagsFolderCont = $('.tags-bulk-folder-cont') + + this.$tagsStatusButton = $('.uk-button-bulk-status-tags') + this.$tagsStatusCont = $('.tags-bulk-status-cont') + + this.$tagsSelectAll = $('.uk-button-select-all') + this.$tagsDeselectAll = $('.uk-button-bulk-deselect') + + this.tagsFolderOpen = false + this.tagsStatusOpen = false + this.tagsIds = null + + this.onCheckboxChange = this.onCheckboxChange.bind(this) + this.tagsFolderButtonClick = this.tagsFolderButtonClick.bind(this) + this.tagsStatusButtonClick = this.tagsStatusButtonClick.bind(this) + this.onSelectAll = this.onSelectAll.bind(this) + this.onDeselectAll = this.onDeselectAll.bind(this) + + if (this.$tagsCheckboxes.length) { + this.init() + } + } + + /** + * Init + */ + init() { + this.$tagsCheckboxes.on('change', this.onCheckboxChange) + this.$tagsStatusButton.on('click', this.tagsStatusButtonClick) + this.$tagsFolderButton.on('click', this.tagsFolderButtonClick) + this.$tagsSelectAll.on('click', this.onSelectAll) + this.$tagsDeselectAll.on('click', this.onDeselectAll) + } + + unbind() { + if (this.$tagsCheckboxes.length) { + this.$tagsCheckboxes.off('change', this.onCheckboxChange) + this.$tagsStatusButton.off('click', this.tagsStatusButtonClick) + this.$tagsFolderButton.off('click', this.tagsFolderButtonClick) + this.$tagsSelectAll.off('click', this.onSelectAll) + this.$tagsDeselectAll.off('click', this.onDeselectAll) + } + } + + onSelectAll() { + this.$tagsCheckboxes.prop('checked', true) + this.onCheckboxChange(null) + + return false + } + + onDeselectAll() { + this.$tagsCheckboxes.prop('checked', false) + this.onCheckboxChange(null) + + return false + } + + /** + * On checkbox change + */ + onCheckboxChange() { + this.tagsIds = [] + + $('input.tag-checkbox:checked').each((index, domElement) => { + this.tagsIds.push($(domElement).val()) + }) + + if (this.$tagsIdBulkTags.length) { + this.$tagsIdBulkTags.val(this.tagsIds.join(',')) + } + if (this.$tagsIdBulkStatus.length) { + this.$tagsIdBulkStatus.val(this.tagsIds.join(',')) + } + + if (this.tagsIds.length > 0) { + this.showActions() + } else { + this.hideActions() + } + + return false + } + + /** + * On bulk delete + */ + onBulkDelete() { + if (this.tagsIds.length > 0) { + history.pushState( + { + headerData: { + tags: this.tagsIds, + }, + }, + null, + window.Rozier.routes.tagsBulkDeletePage + ) + + window.Rozier.lazyload.onPopState(null) + } + + return false + } + + /** + * Show actions + * @return {[type]} [description] + */ + showActions() { + this.$actionsMenu.slideDown() + } + + /** + * Hide actions + */ + hideActions() { + this.$actionsMenu.slideUp() + } + + /** + * Tags folder button click + */ + tagsFolderButtonClick() { + if (!this.tagsFolderOpen) { + this.$tagsStatusCont.slideUp() + this.tagsStatusOpen = false + + this.$tagsFolderCont.slideDown() + this.tagsFolderOpen = true + } else { + this.$tagsFolderCont.slideUp() + this.tagsFolderOpen = false + } + + return false + } + + /** + * Tags status button click + */ + tagsStatusButtonClick() { + if (!this.tagsStatusOpen) { + this.$tagsFolderCont.slideUp() + this.tagsFolderOpen = false + + this.$tagsStatusCont.slideDown() + this.tagsStatusOpen = true + } else { + this.$tagsStatusCont.slideUp() + this.tagsStatusOpen = false + } + + return false + } +} diff --git a/lib/Rozier/src/Resources/app/components/custom-form-fields/CustomFormFieldEdit.js b/lib/Rozier/src/Resources/app/components/custom-form-fields/CustomFormFieldEdit.js new file mode 100644 index 00000000..c298f643 --- /dev/null +++ b/lib/Rozier/src/Resources/app/components/custom-form-fields/CustomFormFieldEdit.js @@ -0,0 +1,159 @@ +import $ from 'jquery' +import { TweenLite, Expo } from 'gsap' + +/** + * Custom form field edit + */ +export default class CustomFormFieldEdit { + constructor() { + // Selectors + this.$btn = $('.custom-form-field-edit-button') + + this.btnClick = this.btnClick.bind(this) + + if (this.$btn.length) { + this.$formFieldRow = $('.custom-form-field-row') + this.$formFieldCol = $('.custom-form-field-col') + this.indexOpen = null + this.openFormDelay = 0 + this.$formCont = null + this.$form = null + this.$formIcon = null + this.$formContHeight = null + + // Methods + this.init() + } + } + + /** + * Init + * @return {[type]} [description] + */ + init() { + // Events + this.$btn.on('click', this.btnClick) + } + + unbind() { + if (this.$btn.length) { + this.$btn.off('click', this.btnClick) + } + } + + /** + * Btn click + */ + btnClick(e) { + e.preventDefault() + + if (this.indexOpen !== null) { + this.closeForm() + this.openFormDelay = 500 + } else { + this.openFormDelay = 0 + } + + if (this.indexOpen !== parseInt(e.currentTarget.getAttribute('data-index'))) { + if (this.openTimeout) { + window.clearTimeout(this.openTimeout) + } + this.openTimeout = window.setTimeout(() => { + this.indexOpen = parseInt(e.currentTarget.getAttribute('data-index')) + $.ajax({ + url: e.currentTarget.href, + type: 'get', + cache: false, + dataType: 'html', + }) + .done((data) => { + this.applyContent(e.currentTarget, data, e.currentTarget.href) + }) + .fail(() => { + window.UIkit.notify({ + message: window.Rozier.messages.forbiddenPage, + status: 'danger', + timeout: 3000, + pos: 'top-center', + }) + }) + }, this.openFormDelay) + } + + return false + } + + /** + * Apply content + */ + applyContent(target, data, url) { + let dataWrapped = [ + '', + '', + '

', + '', + '', + ].join('') + + $(target).parent().parent().after(dataWrapped) + + // Remove class to pause sortable actions + this.$formFieldCol.removeClass('custom-form-field-col') + + // Switch checkboxes + $('.rz-boolean-checkbox').bootstrapSwitch({ + size: 'small', + }) + + window.Rozier.lazyload.initMarkdownEditors() + + window.setTimeout(() => { + this.$formCont = $('.custom-form-field-edit-form-cont') + this.formContHeight = this.$formCont.actual('height') + this.$formRow = $('.custom-form-field-edit-form-row') + this.$form = $('#edit-custom-form-field-form') + this.$formIcon = $(this.$formFieldRow[this.indexOpen]).find('.custom-form-field-col-1 i') + + this.$form.attr('action', url) + this.$formIcon[0].className = 'uk-icon-chevron-down' + + this.$formCont[0].style.height = '0px' + this.$formCont[0].style.display = 'block' + + TweenLite.to(this.$form, 0.6, { + height: this.formContHeight, + ease: Expo.easeOut, + }) + + TweenLite.to(this.$formCont, 0.6, { + height: this.formContHeight, + ease: Expo.easeOut, + }) + }, 200) + } + + /** + * Close form + */ + closeForm() { + this.$formIcon[0].className = 'uk-icon-chevron-right' + + TweenLite.to(this.$formCont, 0.4, { + height: 0, + ease: Expo.easeOut, + onComplete: () => { + this.$formRow.remove() + this.indexOpen = null + this.$formFieldCol.addClass('custom-form-field-col') + }, + }) + } + + /** + * Window resize callback + * @return {[type]} [description] + */ + resize() {} +} diff --git a/lib/Rozier/src/Resources/app/components/custom-form-fields/CustomFormFieldsPosition.js b/lib/Rozier/src/Resources/app/components/custom-form-fields/CustomFormFieldsPosition.js new file mode 100644 index 00000000..43f54f82 --- /dev/null +++ b/lib/Rozier/src/Resources/app/components/custom-form-fields/CustomFormFieldsPosition.js @@ -0,0 +1,70 @@ +import $ from 'jquery' + +/** + * Custom form fields position + */ +export default class CustomFormFieldsPosition { + constructor() { + this.$list = $('.custom-form-fields > .uk-sortable') + this.onSortableChange = this.onSortableChange.bind(this) + this.init() + } + + init() { + if (this.$list.length && this.$list.children().length > 1) { + this.$list.on('change.uk.sortable', this.onSortableChange) + } + } + + unbind() { + if (this.$list.length && this.$list.children().length > 1) { + this.$list.off('change.uk.sortable', this.onSortableChange) + } + } + + onSortableChange(event, list, element) { + let $element = $(element) + let customFormFieldId = parseInt($element.data('field-id')) + let $sibling = $element.prev() + let newPosition = 0.0 + + if ($sibling.length === 0) { + $sibling = $element.next() + newPosition = parseInt($sibling.data('position')) - 0.5 + } else { + newPosition = parseInt($sibling.data('position')) + 0.5 + } + + let postData = { + _token: window.Rozier.ajaxToken, + _action: 'updatePosition', + customFormFieldId: customFormFieldId, + newPosition: newPosition, + } + + $.ajax({ + url: window.Rozier.routes.customFormsFieldAjaxEdit.replace('%customFormFieldId%', customFormFieldId), + type: 'POST', + dataType: 'json', + data: postData, + }) + .done((data) => { + $element.attr('data-position', newPosition) + window.UIkit.notify({ + message: data.responseText, + status: data.status, + timeout: 3000, + pos: 'top-center', + }) + }) + .fail((data) => { + data = JSON.parse(data.responseText) + window.UIkit.notify({ + message: data.error_message, + status: 'danger', + timeout: 3000, + pos: 'top-center', + }) + }) + } +} diff --git a/lib/Rozier/src/Resources/app/components/documents/DocumentUploader.js b/lib/Rozier/src/Resources/app/components/documents/DocumentUploader.js new file mode 100644 index 00000000..b66bd879 --- /dev/null +++ b/lib/Rozier/src/Resources/app/components/documents/DocumentUploader.js @@ -0,0 +1,144 @@ +import $ from 'jquery' +import Dropzone from 'dropzone' + +/** + * Document uploader + */ +export default class DocumentUploader { + /** + * Constructor + * @param {Object} options + */ + constructor(options) { + this.attached = false + this.options = { + onSuccess: (data) => {}, + onError: (data) => {}, + onAdded: (file) => {}, + url: window.Rozier.routes.documentsUploadPage, + selector: '#upload-dropzone-document', + paramName: 'form[attachment]', + uploadMultiple: false, + maxFilesize: 64, + timeout: 0, // no timeout + autoDiscover: false, + headers: { _token: window.Rozier.ajaxToken }, + dictDefaultMessage: 'Drop files here to upload or click to open your explorer', + dictFallbackMessage: "Your browser does not support drag'n'drop file uploads.", + dictFallbackText: 'Please use the fallback form below to upload your files like in the olden days.', + dictFileTooBig: 'File is too big ({{filesize}}MiB). Max filesize: {{maxFilesize}}MiB.', + dictInvalidFileType: "You can't upload files of this type.", + dictResponseError: 'Server responded with {{statusCode}} code.', + dictCancelUpload: 'Cancel upload', + dictCancelUploadConfirmation: 'Are you sure you want to cancel this upload?', + dictRemoveFile: 'Remove file', + dictRemoveFileConfirmation: null, + dictMaxFilesExceeded: 'You can not upload any more files.', + } + + if (typeof options !== 'undefined') { + $.extend(this.options, options) + } + + if ($(this.options.selector).length && !this.attached) { + this.init() + } + } + + init() { + const _self = this + + // Get folder id + let form = $('#upload-dropzone-document') + + if (form.attr('data-folder-id') && form.attr('data-folder-id') > 0) { + this.options.headers.folderId = parseInt(form.attr('data-folder-id')) + this.options.url = window.Rozier.routes.documentsUploadPage + '/' + parseInt(form.attr('data-folder-id')) + } + + Dropzone.options.uploadDropzoneDocument = { + url: this.options.url, + method: 'post', + headers: this.options.headers, + paramName: this.options.paramName, + uploadMultiple: this.options.uploadMultiple, + timeout: this.options.timeout, + maxFilesize: this.options.maxFilesize, + dictDefaultMessage: this.options.dictDefaultMessage, + dictFallbackMessage: this.options.dictFallbackMessage, + dictFallbackText: this.options.dictFallbackText, + dictFileTooBig: this.options.dictFileTooBig, + dictInvalidFileType: this.options.dictInvalidFileType, + dictResponseError: this.options.dictResponseError, + dictCancelUpload: this.options.dictCancelUpload, + dictCancelUploadConfirmation: this.options.dictCancelUploadConfirmation, + dictRemoveFile: this.options.dictRemoveFile, + dictRemoveFileConfirmation: this.options.dictRemoveFileConfirmation, + dictMaxFilesExceeded: this.options.dictMaxFilesExceeded, + init: function () { + this.on('addedfile', function (file, data) { + _self.options.onAdded(file) + }) + + this.on('success', function (file, data) { + /* + * Remove previews after 3 sec not + * to bloat the dropzone when dragging more than + * 20 files… + */ + if (file.previewElement) { + let $preview = $(file.previewElement) + window.setTimeout(() => { + $preview.fadeOut(500, () => { + $preview.remove() + }) + }, 3000) + } + _self.options.onSuccess(data) + window.Rozier.getMessages() + }) + + this.on('canceled', function (file, data) { + _self.options.onError(JSON.parse(data)) + window.Rozier.getMessages() + }) + + this.on('error', function (file, errorMessage, xhr) { + console.error(errorMessage) + }) + + this.on('sending', function (file, xhr, formData) { + xhr.ontimeout = () => { + _self.options.onError('Server Timeout') + console.error('Server Timeout') + } + }) + }, + } + + Dropzone.autoDiscover = this.options.autoDiscover + + try { + /* eslint-disable no-new */ + new Dropzone(this.options.selector, Dropzone.options.uploadDropzoneDocument) + + let $dropzone = $(this.options.selector) + $dropzone.append(`
${this.options.dictDefaultMessage}
`) + let $dzMessage = $dropzone.find('.dz-message') + $dzMessage.append(` +
+
+
+
+
+
+ +
`) + this.attached = true + } catch (e) { + console.error(e) + } + } + + unbind() {} +} diff --git a/lib/Rozier/src/Resources/app/components/documents/DocumentsList.js b/lib/Rozier/src/Resources/app/components/documents/DocumentsList.js new file mode 100644 index 00000000..9814a5da --- /dev/null +++ b/lib/Rozier/src/Resources/app/components/documents/DocumentsList.js @@ -0,0 +1,24 @@ +import $ from 'jquery' + +/** + * Documents list + */ +export default class DocumentsList { + constructor() { + // Selectors + this.$cont = $('.documents-list') + + if (this.$cont.length) this.$item = this.$cont.find('.document-item') + + this.contWidth = null + this.itemWidth = 144 // (w : 128 + mr : 16) + this.itemsPerLine = 4 + this.itemsWidth = 576 + this.contMarginLeft = 0 + } + + /** + * Window resize callback + */ + resize() {} +} diff --git a/lib/Rozier/src/Resources/app/components/import/Import.js b/lib/Rozier/src/Resources/app/components/import/Import.js new file mode 100644 index 00000000..7a92eb93 --- /dev/null +++ b/lib/Rozier/src/Resources/app/components/import/Import.js @@ -0,0 +1,119 @@ +import $ from 'jquery' + +/** + * Import + */ +export default class Import { + /** + * Constructor + * @param {Array} routesArray + */ + constructor(routesArray) { + this.routes = routesArray + this.always(0, this.routes) + this.$nextStepButton = $('#next-step-button') + this.score = 0 + } + + always(index, routes) { + if (routes.length > index) { + if (typeof routes[index].update !== 'undefined') { + $.ajax({ + url: routes[index].update, + type: 'POST', + dataType: 'json', + complete: () => { + this.callSingleImport(index) + }, + }) + } else { + this.callSingleImport(index) + } + } else if (this.$nextStepButton.length) { + this.$nextStepButton.removeClass('uk-button-disabled') + } + } + + callSingleImport(index) { + const currentIndex = index + const routes = this.routes + let $row = $('#' + routes[currentIndex].id) + let $icon = $row.find('i') + $icon.removeClass('uk-icon-circle-o') + $icon.addClass('uk-icon-spin') + $icon.addClass('uk-icon-spinner') + + let postData = { + filename: routes[currentIndex].filename, + } + + $.ajax({ + url: routes[currentIndex].url, + type: 'POST', + dataType: 'json', + data: postData, + success: () => { + $icon.removeClass('uk-icon-spin') + $icon.removeClass('uk-icon-spinner') + $icon.addClass('uk-icon-check') + $row.addClass('uk-badge-success') + + // Call post-update route + if (routes[currentIndex].postUpdate) { + if ( + routes[currentIndex].postUpdate instanceof Array && + routes[currentIndex].postUpdate.length > 1 + ) { + // Call clear cache before updating schema + $.ajax({ + url: routes[currentIndex].postUpdate[0], + type: 'POST', + dataType: 'json', + complete: () => { + $.ajax({ + url: routes[currentIndex].postUpdate[1], + type: 'POST', + dataType: 'json', + complete: () => { + this.always(currentIndex + 1, routes) + }, + }) + }, + }) + } else { + $.ajax({ + url: routes[currentIndex].postUpdate, + type: 'POST', + dataType: 'json', + complete: () => { + this.always(currentIndex + 1, routes) + }, + }) + } + } else { + this.always(currentIndex + 1, routes) + } + }, + error: (data) => { + $icon.removeClass('uk-icon-spin') + $icon.removeClass('uk-icon-spinner') + $icon.addClass('uk-icon-warning') + $row.addClass('uk-badge-danger') + + if (data.responseJSON && data.responseJSON.error) { + $row.parent() + .parent() + .after( + '' + + data.responseJSON.error + + '' + ) + } + }, + complete: () => { + $icon.removeClass('uk-icon-spin') + $icon.removeClass('uk-icon-spinner') + }, + }) + } +} diff --git a/lib/Rozier/src/Resources/app/components/login/login.js b/lib/Rozier/src/Resources/app/components/login/login.js new file mode 100644 index 00000000..89058cbe --- /dev/null +++ b/lib/Rozier/src/Resources/app/components/login/login.js @@ -0,0 +1,37 @@ +import $ from 'jquery' +import request from 'axios' +;(function () { + const onLoad = function (data) { + const $splashContainer = $('#splash-container') + $splashContainer.css({ + 'background-image': 'url(' + data.url + ')', + }) + $splashContainer.addClass('visible') + } + + const requestImage = function () { + request({ + method: 'GET', + url: window.RozierRoot.routes.splashRequest, + headers: { + 'X-Requested-With': 'XMLHttpRequest', + }, + withCredentials: false, + responseType: 'json', + }) + .then((response) => { + if (typeof response.data !== 'undefined' && typeof response.data.url !== 'undefined') { + let myImage = new Image(window.width, window.height) + myImage.src = response.data.url + myImage.onload = $.proxy(onLoad, this, response.data) + } + }) + .catch((error) => { + console.error(error.response.data.humanMessage) + }) + } + + if (typeof window.RozierRoot.routes.splashRequest !== 'undefined') { + requestImage() + } +})() diff --git a/lib/Rozier/src/Resources/app/components/node-type-fields/NodeTypeFieldEdit.js b/lib/Rozier/src/Resources/app/components/node-type-fields/NodeTypeFieldEdit.js new file mode 100644 index 00000000..1e91a87f --- /dev/null +++ b/lib/Rozier/src/Resources/app/components/node-type-fields/NodeTypeFieldEdit.js @@ -0,0 +1,167 @@ +import $ from 'jquery' +import { TweenLite, Expo } from 'gsap' + +/** + * NODE TYPE FIELD EDIT + */ +export default class NodeTypeFieldEdit { + constructor() { + // Selectors + this.$btn = $('.node-type-field-edit-button') + this.currentRequest = null + if (this.$btn.length) { + this.$formFieldRow = $('.node-type-field-row') + this.$formFieldCol = $('.node-type-field-col') + + this.indexOpen = null + this.openFormDelay = 0 + this.$formCont = null + this.$form = null + this.$formIcon = null + this.$formContHeight = null + + // Methods + this.init() + } + } + + /** + * Init + */ + init() {} + + unbind() {} + + /** + * Btn click + * @param {Event} e + * @returns {boolean} + */ + btnClick(e) { + e.preventDefault() + + if (this.indexOpen !== null) { + this.closeForm() + this.openFormDelay = 400 + } else this.openFormDelay = 0 + + if (this.indexOpen !== parseInt(e.currentTarget.getAttribute('data-index'))) { + if (this.currentRequest && this.currentRequest.readyState !== 4) { + this.currentRequest.abort() + } + window.Rozier.lazyload.canvasLoader.show() + + if (this.openTimeout) { + clearTimeout(this.openTimeout) + } + + this.openTimeout = setTimeout(() => { + // Trigger event on window to notify open + // widgets to close. + let pageChangeEvent = new CustomEvent('pagechange') + window.dispatchEvent(pageChangeEvent) + + this.indexOpen = parseInt(e.currentTarget.getAttribute('data-index')) + + this.currentRequest = $.ajax({ + url: e.currentTarget.href, + type: 'get', + cache: false, + dataType: 'html', + }) + .done((data) => { + this.applyContent(e.currentTarget, data, e.currentTarget.href) + }) + .fail(() => { + window.UIkit.notify({ + message: window.Rozier.messages.forbiddenPage, + status: 'danger', + timeout: 3000, + pos: 'top-center', + }) + }) + .always(() => { + window.Rozier.lazyload.canvasLoader.hide() + }) + }, this.openFormDelay) + } + + return false + } + + /** + * Apply content + * @param target + * @param data + * @param url + */ + applyContent(target, data, url) { + let dataWrapped = [ + '', + '', + '
', + data, + '
', + '', + '', + ].join('') + + $(target).parent().parent().after(dataWrapped) + + // Remove class to pause sortable actions + this.$formFieldCol.removeClass('node-type-field-col') + + // Switch checkboxes + $('.rz-boolean-checkbox').bootstrapSwitch({ + size: 'small', + }) + + window.Rozier.lazyload.initMarkdownEditors() + + window.setTimeout(() => { + this.$formCont = $('.node-type-field-edit-form-cont') + this.formContHeight = this.$formCont.actual('height') + this.$formRow = $('.node-type-field-edit-form-row') + this.$form = $('#edit-node-type-field-form') + this.$formIcon = $(this.$formFieldRow[this.indexOpen]).find('.node-type-field-col-1 i') + + this.$form.attr('action', url) + this.$formIcon[0].className = 'uk-icon-chevron-down' + + this.$formCont[0].style.height = '0px' + this.$formCont[0].style.display = 'block' + + TweenLite.to(this.$form, 0.6, { + height: this.formContHeight, + ease: Expo.easeOut, + }) + + TweenLite.to(this.$formCont, 0.6, { + height: this.formContHeight, + ease: Expo.easeOut, + }) + }, 200) + } + + /** + * Close form + */ + closeForm() { + this.$formIcon[0].className = 'uk-icon-chevron-right' + + TweenLite.to(this.$formCont, 0.4, { + height: 0, + ease: Expo.easeOut, + onComplete: () => { + this.$formRow.remove() + this.indexOpen = null + this.$formFieldCol.addClass('node-type-field-col') + }, + }) + } + + /** + * Window resize callback + */ + resize() {} +} diff --git a/lib/Rozier/src/Resources/app/components/node-type-fields/NodeTypeFieldsPosition.js b/lib/Rozier/src/Resources/app/components/node-type-fields/NodeTypeFieldsPosition.js new file mode 100644 index 00000000..91ff566b --- /dev/null +++ b/lib/Rozier/src/Resources/app/components/node-type-fields/NodeTypeFieldsPosition.js @@ -0,0 +1,118 @@ +/* + * Copyright (c) 2017. Ambroise Maupate and Julien Blanchet + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is furnished + * to do so, subject to the following conditions: + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS + * OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL + * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + * + * Except as contained in this notice, the name of the ROADIZ shall not + * be used in advertising or otherwise to promote the sale, use or other dealings + * in this Software without prior written authorization from Ambroise Maupate and Julien Blanchet. + * + * @file nodeTypeFieldsPosition.js + * @author Adrien Scholaert + * @author Ambroise Maupate + */ + +import $ from 'jquery' + +/** + * Node type fields position + */ +export default class NodeTypeFieldsPosition { + /** + * Constructor + */ + constructor() { + this.$list = $('.node-type-fields > .uk-sortable') + this.currentRequest = null + + // Bind methods + this.onSortableChange = this.onSortableChange.bind(this) + + this.init() + } + + /** + * Init + */ + init() { + if (this.$list.length && this.$list.children().length > 1) { + this.$list.on('change.uk.sortable', this.onSortableChange) + } + } + + unbind() { + if (this.$list.length && this.$list.children().length > 1) { + this.$list.off('change.uk.sortable', this.onSortableChange) + } + } + + /** + * @param event + * @param list + * @param element + */ + onSortableChange(event, list, element) { + if (this.currentRequest && this.currentRequest.readyState !== 4) { + this.currentRequest.abort() + } + + let $element = $(element) + let nodeTypeFieldId = parseInt($element.data('field-id')) + let $sibling = $element.prev() + let newPosition = 0.0 + + if ($sibling.length === 0) { + $sibling = $element.next() + newPosition = parseInt($sibling.data('position')) - 0.5 + } else { + newPosition = parseInt($sibling.data('position')) + 0.5 + } + + let postData = { + _token: window.Rozier.ajaxToken, + _action: 'updatePosition', + nodeTypeFieldId: nodeTypeFieldId, + newPosition: newPosition, + } + + this.currentRequest = $.ajax({ + url: window.Rozier.routes.nodeTypesFieldAjaxEdit.replace('%nodeTypeFieldId%', nodeTypeFieldId), + type: 'POST', + dataType: 'json', + data: postData, + }) + .done((data) => { + $element.attr('data-position', newPosition) + window.UIkit.notify({ + message: data.responseText, + status: data.status, + timeout: 3000, + pos: 'top-center', + }) + }) + .fail((data) => { + data = JSON.parse(data.responseText) + window.UIkit.notify({ + message: data.error_message, + status: 'danger', + timeout: 3000, + pos: 'top-center', + }) + }) + } +} diff --git a/lib/Rozier/src/Resources/app/components/node/NodeEditSource.js b/lib/Rozier/src/Resources/app/components/node/NodeEditSource.js new file mode 100644 index 00000000..a8de6277 --- /dev/null +++ b/lib/Rozier/src/Resources/app/components/node/NodeEditSource.js @@ -0,0 +1,320 @@ +import $ from 'jquery' +import { toType } from '../../utils/plugins' + +/** + * Node edit source + */ +export default class NodeEditSource { + constructor() { + // Selectors + this.$content = $('.content-node-edit-source').eq(0) + this.$form = $('#edit-node-source-form') + this.$formRow = null + this.$dropdown = null + this.$input = null + + // Binded methods + this.onInputKeyDown = this.onInputKeyDown.bind(this) + this.onInputKeyUp = this.onInputKeyUp.bind(this) + this.inputFocus = this.inputFocus.bind(this) + this.inputFocusOut = this.inputFocusOut.bind(this) + this.onFormSubmit = this.onFormSubmit.bind(this) + this.wrapInTabs = this.wrapInTabs.bind(this) + + // Methods + if (this.$content.length) { + this.$formRow = this.$content.find('.uk-form-row') + window.setTimeout(this.wrapInTabs, 300) + this.init() + this.initEvents() + } + } + + wrapInTabs() { + let fieldGroups = { + default: { + name: 'default', + id: 'default', + fields: [], + }, + } + let $fields = this.$content.find('.uk-form-row[data-field-group-canonical]') + let fieldsLength = $fields.length + let fieldsGroupsLength = 1 + + if (fieldsLength > 0) { + for (let i = 0; i < fieldsLength; i++) { + let groupName = $fields[i].getAttribute('data-field-group') + let groupNameCanonical = $fields[i].getAttribute('data-field-group-canonical') + if (groupNameCanonical) { + if (typeof fieldGroups[groupNameCanonical] === 'undefined') { + fieldGroups[groupNameCanonical] = { + name: groupName, + id: groupNameCanonical, + fields: [], + } + fieldsGroupsLength++ + } + fieldGroups[groupNameCanonical].fields.push($fields[i]) + } else { + fieldGroups['default'].fields.push($fields[i]) + } + } + + if (fieldsGroupsLength > 1) { + this.$form.append( + '
' + + '
    ' + + '
' + + '
' + + '
    ' + + '
' + ) + let $formSwitcher = this.$form.find('.uk-switcher') + let $formSwitcherNav = this.$form.find('#node-source-form-switcher-nav') + + for (let index in fieldGroups) { + let fieldGroup = fieldGroups[index] + let groupName2Safe = fieldGroup.id.replace(/[\s_]/g, '-').replace(/[^\w-]+/g, '') + let groupId = 'group-' + groupName2Safe + + $formSwitcher.append('
  • ') + + if (fieldGroup.id === 'default') { + $formSwitcherNav.append( + '
  • ' + ) + } else { + $formSwitcherNav.append( + '
  • ' + fieldGroup.name + '
  • ' + ) + } + let $group = $formSwitcher.find('#' + groupId) + + for (let index = 0; index < fieldGroup.fields.length; index++) { + $group.append($(fieldGroup.fields[index])) + } + } + + $formSwitcherNav.on('show.uk.switcher', () => { + window.setTimeout(() => { + window.Rozier.$window.trigger('resize') + window.Rozier.lazyload.refreshCodemirrorEditor() + }, 100) + }) + } + } + + this.$content.addClass('content-tabs-ready') + } + + /** + * Init + */ + init() { + // Inputs - add form help + this.$input = this.$content.find('input, select') + this.$devNames = this.$content.find('[data-dev-name]') + + for (let j = this.$devNames.length - 1; j >= 0; j--) { + let input = this.$devNames[j] + let $input = $(input) + if (input.getAttribute('data-dev-name') !== '') { + let $label = $input.parents('.uk-form-row').find('label') + let $barLabel = $input.find('.uk-navbar-brand.label') + + if ($label.length) { + $label.append('' + input.getAttribute('data-dev-name') + '') + } else if ($barLabel.length) { + $barLabel.append('' + input.getAttribute('data-dev-name') + '') + } + } + } + + // Check if children node widget needs his dropdowns to be flipped up + for (let k = this.$formRow.length - 1; k >= 0; k--) { + if (this.$formRow[k].className.indexOf('children-nodes-widget') >= 0) { + this.childrenNodeWidgetFlip(k) + break + } + } + } + + initEvents() { + window.Rozier.$window.on('keydown', this.onInputKeyDown) + window.Rozier.$window.on('keyup', this.onInputKeyUp) + this.$input.on('focus', this.inputFocus) + this.$input.on('focusout', this.inputFocusOut) + this.$form.on('submit', this.onFormSubmit) + } + + unbind() { + if (this.$content.length) { + window.Rozier.$window.off('keydown', this.onInputKeyDown) + window.Rozier.$window.off('keyup', this.onInputKeyUp) + this.$input.off('focus', this.inputFocus) + this.$input.off('focusout', this.inputFocusOut) + this.$form.off('submit', this.onFormSubmit) + } + } + + onFormSubmit() { + window.Rozier.lazyload.canvasLoader.show() + + if (this.currentTimeout) { + clearTimeout(this.currentTimeout) + } + + this.currentTimeout = setTimeout(() => { + /* + * Trigger event on window to notify open + * widgets to close. + */ + let pageChangeEvent = new CustomEvent('pagechange') + window.dispatchEvent(pageChangeEvent) + + let formData = new FormData(this.$form.get(0)) + + $.ajax({ + url: window.location.href, + type: 'post', + data: formData, + processData: false, + cache: false, + contentType: false, + }) + .done((data) => { + this.cleanErrors() + // Update preview or public url + if (data.public_url) { + let $publicUrlLinks = $('a.public-url-link') + if ($publicUrlLinks.length) { + $publicUrlLinks.attr('href', data.public_url) + } + } + if (data.preview_url) { + let $previewUrlLinks = $('a.preview-url-link') + if ($previewUrlLinks.length) { + $previewUrlLinks.attr('href', data.preview_url) + } + } + }) + .fail((data) => { + if (data.responseJSON) { + this.displayErrors(data.responseJSON.errors) + window.UIkit.notify({ + message: data.responseJSON.message, + status: 'danger', + timeout: 2000, + pos: 'top-center', + }) + } + }) + .always(() => { + window.Rozier.lazyload.canvasLoader.hide() + window.Rozier.getMessages() + window.Rozier.refreshAllNodeTrees() + }) + }, 300) + + return false + } + + cleanErrors() { + const $previousErrors = $('.form-errored') + $previousErrors.each((index) => { + $previousErrors.eq(index).removeClass('form-errored') + $previousErrors.eq(index).find('.error-message').remove() + }) + } + + /** + * + * @param {Array} errors + * @param {Boolean} keepExisting Keep existing errors. + */ + displayErrors(errors, keepExisting) { + // First clean fields + if (!keepExisting || keepExisting === false) { + this.cleanErrors() + } + + for (let key in errors) { + let classKey = null + let errorMessage = null + if (toType(errors[key]) === 'object') { + this.displayErrors(errors[key], true) + } else { + classKey = key.replace('_', '-') + if (errors[key] instanceof Array) { + errorMessage = errors[key][0] + } else { + errorMessage = errors[key] + } + let $field = $('.form-col-' + classKey) + if ($field.length) { + $field.addClass('form-errored') + $field.append( + '

    ' + + errorMessage + + '

    ' + ) + } + } + } + } + + /** + * On keyboard key down + * @param {Event} event + */ + onInputKeyDown(event) { + // ALT key + if (event.keyCode === 18) { + window.Rozier.$body.toggleClass('dev-name-visible') + } + } + + /** + * On keyboard key up + * @param {Event} event + */ + onInputKeyUp(event) { + // ALT key + if (event.keyCode === 18) { + window.Rozier.$body.toggleClass('dev-name-visible') + } + } + + /** + * Flip children node widget + * @param {Number} index + */ + childrenNodeWidgetFlip(index) { + if (index >= this.$formRow.length - 2) { + this.$dropdown = $(this.$formRow[index]).find('.uk-dropdown-small') + this.$dropdown.addClass('uk-dropdown-up') + } + } + + /** + * Input focus + * @param {Event} e + */ + inputFocus(e) { + $(e.currentTarget).parent().addClass('form-col-focus') + } + + /** + * Input focus out + * @param {Event} e + */ + inputFocusOut(e) { + $(e.currentTarget).parent().removeClass('form-col-focus') + } + + /** + * Window resize callback + */ + resize() {} +} diff --git a/lib/Rozier/src/Resources/app/components/panels/EntriesPanel.js b/lib/Rozier/src/Resources/app/components/panels/EntriesPanel.js new file mode 100644 index 00000000..85a5f710 --- /dev/null +++ b/lib/Rozier/src/Resources/app/components/panels/EntriesPanel.js @@ -0,0 +1,28 @@ +import $ from 'jquery' + +/** + * Entries panel + */ +export default class EntriesPanel { + constructor() { + this.$adminMenuNav = $('#admin-menu-nav') + this.replaceSubNavs() + } + + replaceSubNavs() { + this.$adminMenuNav.find('.uk-nav-sub').each((index, element) => { + let subMenu = $(element) + + subMenu.attr('style', 'display:block;') + + const top = subMenu.offset().top + const height = subMenu.height() + + subMenu.removeAttr('style') + + if (top + height + 20 > $(window).height()) { + subMenu.parent().addClass('reversed-nav') + } + }) + } +} diff --git a/lib/Rozier/src/Resources/app/components/tabs/MainTreeTabs.js b/lib/Rozier/src/Resources/app/components/tabs/MainTreeTabs.js new file mode 100644 index 00000000..d5b7b209 --- /dev/null +++ b/lib/Rozier/src/Resources/app/components/tabs/MainTreeTabs.js @@ -0,0 +1,32 @@ +import $ from 'jquery' + +const STORAGE_KEY = 'roadiz.currentMainTreeTab' + +export default class MainTreeTabs { + constructor() { + this.onTabChange = this.onTabChange.bind(this) + this.tabsMenu = $('#tree-menu') + const currentTabId = window.localStorage.getItem(STORAGE_KEY) + + if (this.tabsMenu) { + window.UIkit.tab(this.tabsMenu, { + connect: '#tree-container', + swiping: false, + active: currentTabId ? Number.parseInt(currentTabId) : 0, + }) + this.tabsMenu.on('change.uk.tab', this.onTabChange) + } + } + + unbind() { + this.tabsMenu.off('change.uk.tab', this.onTabChange) + } + + onTabChange(event, activeItem, previousItem) { + activeItem = activeItem[0] + const index = activeItem.getAttribute('data-index') + if (index) { + window.localStorage.setItem(STORAGE_KEY, index) + } + } +} diff --git a/lib/Rozier/src/Resources/app/components/tag/TagEdit.js b/lib/Rozier/src/Resources/app/components/tag/TagEdit.js new file mode 100644 index 00000000..824900b7 --- /dev/null +++ b/lib/Rozier/src/Resources/app/components/tag/TagEdit.js @@ -0,0 +1,131 @@ +import $ from 'jquery' +import { toType } from '../../utils/plugins' + +/** + * Node edit source + */ +export default class TagEdit { + constructor() { + // Selectors + this.$content = $('.content-tag-edit').eq(0) + this.$form = $('#edit-tag-form') + this.$formRow = null + this.$dropdown = null + + // Binded methods + this.onFormSubmit = this.onFormSubmit.bind(this) + + // Methods + if (this.$content.length) { + this.$formRow = this.$content.find('.uk-form-row') + this.initEvents() + } + } + + initEvents() { + this.$form.on('submit', this.onFormSubmit) + } + + unbind() { + if (this.$content.length) { + this.$form.off('submit', this.onFormSubmit) + } + } + + onFormSubmit() { + window.Rozier.lazyload.canvasLoader.show() + + if (this.currentTimeout) { + clearTimeout(this.currentTimeout) + } + + this.currentTimeout = setTimeout(() => { + /* + * Trigger event on window to notify open + * widgets to close. + */ + let pageChangeEvent = new CustomEvent('pagechange') + window.dispatchEvent(pageChangeEvent) + + let formData = new FormData(this.$form.get(0)) + + $.ajax({ + url: window.location.href, + type: 'post', + data: formData, + processData: false, + cache: false, + contentType: false, + }) + .done((data) => { + this.cleanErrors() + }) + .fail((data) => { + if (data.responseJSON) { + this.displayErrors(data.responseJSON.errors) + window.UIkit.notify({ + message: data.responseJSON.message, + status: 'danger', + timeout: 2000, + pos: 'top-center', + }) + } + }) + .always(() => { + window.Rozier.lazyload.canvasLoader.hide() + window.Rozier.refreshMainTagTree() + window.Rozier.getMessages() + }) + }, 300) + + return false + } + + cleanErrors() { + const $previousErrors = $('.form-errored') + $previousErrors.each((index) => { + $previousErrors.eq(index).removeClass('form-errored') + $previousErrors.eq(index).find('.error-message').remove() + }) + } + + /* + * @param {Array} errors + * @param {Boolean} keepExisting Keep existing errors. + */ + displayErrors(errors, keepExisting) { + // First clean fields + if (!keepExisting || keepExisting === false) { + this.cleanErrors() + } + + for (let key in errors) { + let classKey = null + let errorMessage = null + if (toType(errors[key]) === 'object') { + this.displayErrors(errors[key], true) + } else { + classKey = key.replace('_', '-') + if (errors[key] instanceof Array) { + errorMessage = errors[key][0] + } else { + errorMessage = errors[key] + } + let $field = $('.form-col-' + classKey) + if ($field.length) { + $field.addClass('form-errored') + $field.append( + '

    ' + + errorMessage + + '

    ' + ) + } + } + } + } + + /** + * Window resize callback + */ + resize() {} +} diff --git a/lib/Rozier/src/Resources/app/components/trees/NodeTreeContextActions.js b/lib/Rozier/src/Resources/app/components/trees/NodeTreeContextActions.js new file mode 100644 index 00000000..37761209 --- /dev/null +++ b/lib/Rozier/src/Resources/app/components/trees/NodeTreeContextActions.js @@ -0,0 +1,196 @@ +import $ from 'jquery' + +export default class NodeTreeContextActions { + constructor() { + this.$contextualMenus = $('.tree-contextualmenu') + this.$links = this.$contextualMenus.find('.node-actions a') + this.$nodeMoveFirstLinks = this.$contextualMenus.find('a.move-node-first-position') + this.$nodeMoveLastLinks = this.$contextualMenus.find('a.move-node-last-position') + + this.onClick = this.onClick.bind(this) + this.moveNodeToPosition = this.moveNodeToPosition.bind(this) + + if (this.$links.length) { + this.bind() + } + } + + bind() { + this.$links.on('click', this.onClick) + this.$nodeMoveFirstLinks.on('click', (e) => this.moveNodeToPosition('first', e)) + this.$nodeMoveLastLinks.on('click', (e) => this.moveNodeToPosition('last', e)) + } + + unbind() { + this.$links.off('click', this.onClick) + this.$nodeMoveFirstLinks.off('click') + this.$nodeMoveLastLinks.off('click') + } + + onClick(event) { + event.preventDefault() + + let $link = $(event.currentTarget) + let $element = $($link.parents('.nodetree-element')[0]) + let nodeId = parseInt($element.data('node-id')) + let statusName = $link.attr('data-status') + let statusValue = $link.attr('data-value') + let action = $link.attr('data-action') + + if (typeof action !== 'undefined') { + window.Rozier.lazyload.canvasLoader.show() + + if (typeof statusName !== 'undefined' && typeof statusValue !== 'undefined') { + // Change node status + this.changeStatus(nodeId, statusName, statusValue) + } else { + // Other actions + if (action === 'duplicate') { + this.duplicateNode(nodeId) + } + } + } + } + + changeStatus(nodeId, statusName, statusValue) { + if (this.ajaxTimeout) { + window.clearTimeout(this.ajaxTimeout) + } + + this.ajaxTimeout = window.setTimeout(() => { + let postData = { + _token: window.Rozier.ajaxToken, + _action: 'nodeChangeStatus', + nodeId: nodeId, + statusName: statusName, + statusValue: statusValue, + } + + $.ajax({ + url: window.Rozier.routes.nodesStatusesAjax, + type: 'post', + dataType: 'json', + data: postData, + }) + .done(() => { + window.Rozier.refreshAllNodeTrees() + window.Rozier.getMessages() + }) + .fail((data) => { + data = JSON.parse(data.responseText) + window.UIkit.notify({ + message: data.message, + status: 'danger', + timeout: 3000, + pos: 'top-center', + }) + }) + .always(() => { + window.Rozier.lazyload.canvasLoader.hide() + }) + }, 100) + } + + /** + * Move a node to the position. + * + * @param nodeId + */ + duplicateNode(nodeId) { + if (this.ajaxTimeout) { + window.clearTimeout(this.ajaxTimeout) + } + + this.ajaxTimeout = window.setTimeout(() => { + let postData = { + _token: window.Rozier.ajaxToken, + _action: 'duplicate', + nodeId: nodeId, + } + + $.ajax({ + url: window.Rozier.routes.nodeAjaxEdit.replace('%nodeId%', nodeId), + type: 'POST', + dataType: 'json', + data: postData, + }) + .done(() => { + window.Rozier.refreshAllNodeTrees() + window.Rozier.getMessages() + }) + .fail((data) => { + data = JSON.parse(data.responseText) + window.UIkit.notify({ + message: data.error_message, + status: 'danger', + timeout: 3000, + pos: 'top-center', + }) + }) + .always(() => { + window.Rozier.lazyload.canvasLoader.hide() + }) + }, 100) + } + + /** + * Move a node to the position. + * + * @param {String} position + * @param {Event} event + */ + moveNodeToPosition(position, event) { + window.Rozier.lazyload.canvasLoader.show() + + let element = $($(event.currentTarget).parents('.nodetree-element')[0]) + let nodeId = parseInt(element.data('node-id')) + let parentNodeId = parseInt(element.parents('ul').first().data('parent-node-id')) + let postData = { + _token: window.Rozier.ajaxToken, + _action: 'updatePosition', + nodeId: nodeId, + } + + /* + * Force to first position + */ + if (typeof position !== 'undefined' && position === 'first') { + postData.firstPosition = true + } else if (typeof position !== 'undefined' && position === 'last') { + postData.lastPosition = true + } + + /* + * When dropping to root + * set parentNodeId to NULL + */ + if (isNaN(parentNodeId)) { + parentNodeId = null + } + + postData.newParent = parentNodeId + + $.ajax({ + url: window.Rozier.routes.nodeAjaxEdit.replace('%nodeId%', nodeId), + type: 'POST', + dataType: 'json', + data: postData, + }) + .done(() => { + window.Rozier.refreshAllNodeTrees() + window.Rozier.getMessages() + }) + .fail((data) => { + data = JSON.parse(data.responseText) + window.UIkit.notify({ + message: data.error_message, + status: 'danger', + timeout: 3000, + pos: 'top-center', + }) + }) + .always(() => { + window.Rozier.lazyload.canvasLoader.hide() + }) + } +} diff --git a/lib/Rozier/src/Resources/app/containers/BlanchetteEditorContainer.vue b/lib/Rozier/src/Resources/app/containers/BlanchetteEditorContainer.vue new file mode 100644 index 00000000..5508b8b0 --- /dev/null +++ b/lib/Rozier/src/Resources/app/containers/BlanchetteEditorContainer.vue @@ -0,0 +1,269 @@ + + + diff --git a/lib/Rozier/src/Resources/app/containers/DocumentPreviewContainer.vue b/lib/Rozier/src/Resources/app/containers/DocumentPreviewContainer.vue new file mode 100644 index 00000000..4a32e7b2 --- /dev/null +++ b/lib/Rozier/src/Resources/app/containers/DocumentPreviewContainer.vue @@ -0,0 +1,208 @@ + + + diff --git a/lib/Rozier/src/Resources/app/containers/DrawerContainer.vue b/lib/Rozier/src/Resources/app/containers/DrawerContainer.vue new file mode 100644 index 00000000..1148dce9 --- /dev/null +++ b/lib/Rozier/src/Resources/app/containers/DrawerContainer.vue @@ -0,0 +1,158 @@ + + diff --git a/lib/Rozier/src/Resources/app/containers/ExplorerContainer.vue b/lib/Rozier/src/Resources/app/containers/ExplorerContainer.vue new file mode 100644 index 00000000..0ce38f71 --- /dev/null +++ b/lib/Rozier/src/Resources/app/containers/ExplorerContainer.vue @@ -0,0 +1,147 @@ + + + diff --git a/lib/Rozier/src/Resources/app/containers/FilterExplorerContainer.vue b/lib/Rozier/src/Resources/app/containers/FilterExplorerContainer.vue new file mode 100644 index 00000000..6cf4c2dd --- /dev/null +++ b/lib/Rozier/src/Resources/app/containers/FilterExplorerContainer.vue @@ -0,0 +1,77 @@ + + + diff --git a/lib/Rozier/src/Resources/app/containers/ModalContainer.vue b/lib/Rozier/src/Resources/app/containers/ModalContainer.vue new file mode 100644 index 00000000..dfd5510e --- /dev/null +++ b/lib/Rozier/src/Resources/app/containers/ModalContainer.vue @@ -0,0 +1,47 @@ + + + diff --git a/lib/Rozier/src/Resources/app/containers/NodeTypeFieldFormContainer.vue b/lib/Rozier/src/Resources/app/containers/NodeTypeFieldFormContainer.vue new file mode 100644 index 00000000..d89a8823 --- /dev/null +++ b/lib/Rozier/src/Resources/app/containers/NodeTypeFieldFormContainer.vue @@ -0,0 +1,179 @@ + + diff --git a/lib/Rozier/src/Resources/app/containers/NodeTypesDrawerContainer.vue b/lib/Rozier/src/Resources/app/containers/NodeTypesDrawerContainer.vue new file mode 100644 index 00000000..fbca6db5 --- /dev/null +++ b/lib/Rozier/src/Resources/app/containers/NodeTypesDrawerContainer.vue @@ -0,0 +1,125 @@ + + + diff --git a/lib/Rozier/src/Resources/app/containers/NodesSearchContainer.vue b/lib/Rozier/src/Resources/app/containers/NodesSearchContainer.vue new file mode 100644 index 00000000..e38e51e6 --- /dev/null +++ b/lib/Rozier/src/Resources/app/containers/NodesSearchContainer.vue @@ -0,0 +1,50 @@ + + + diff --git a/lib/Rozier/src/Resources/app/containers/TagCreatorContainer.vue b/lib/Rozier/src/Resources/app/containers/TagCreatorContainer.vue new file mode 100644 index 00000000..a78c905b --- /dev/null +++ b/lib/Rozier/src/Resources/app/containers/TagCreatorContainer.vue @@ -0,0 +1,68 @@ + + + diff --git a/lib/Rozier/src/Resources/app/containers/TagsEditorContainer.vue b/lib/Rozier/src/Resources/app/containers/TagsEditorContainer.vue new file mode 100644 index 00000000..fac34d73 --- /dev/null +++ b/lib/Rozier/src/Resources/app/containers/TagsEditorContainer.vue @@ -0,0 +1,70 @@ + + + diff --git a/lib/Rozier/src/Resources/app/directives/DynamicImg.js b/lib/Rozier/src/Resources/app/directives/DynamicImg.js new file mode 100644 index 00000000..09a4bdba --- /dev/null +++ b/lib/Rozier/src/Resources/app/directives/DynamicImg.js @@ -0,0 +1,20 @@ +import $ from 'jquery' + +/** + * Dynamic img directive to display image with a fade animation when load's complete. + */ +export default { + bind(el, binding) { + let img = new Image() + img.src = binding.value + img.onload = () => { + el.src = binding.value + $(el).css('opacity', 0).animate( + { + opacity: 1, + }, + 1000 + ) + } + }, +} diff --git a/lib/Rozier/src/Resources/app/factories/EntityAwareFactory.js b/lib/Rozier/src/Resources/app/factories/EntityAwareFactory.js new file mode 100644 index 00000000..c3ea7cd5 --- /dev/null +++ b/lib/Rozier/src/Resources/app/factories/EntityAwareFactory.js @@ -0,0 +1,95 @@ +import { + DOCUMENT_ENTITY, + NODE_ENTITY, + NODE_TYPE_ENTITY, + JOIN_ENTITY, + CUSTOM_FORM_ENTITY, + TAG_ENTITY, + EXPLORER_PROVIDER_ENTITY, +} from '../types/entityTypes' + +// Components +import DocumentPreviewListItem from '../components/DocumentPreviewListItem.vue' +import NodePreviewItem from '../components/NodePreviewItem.vue' +import JoinPreviewItem from '../components/JoinPreviewItem.vue' +import CustomFormPreviewItem from '../components/CustomFormPreviewItem.vue' +import NodeTypePreviewItem from '../components/NodeTypePreviewItem.vue' +import TagPreviewItem from '../components/TagPreviewItem.vue' + +// Containers +import TagCreatorContainer from '../containers/TagCreatorContainer.vue' + +export default class EntityAwareFactory { + static getState(entity) { + const result = { + trans: { + moreItems: '', + }, + } + + // Default + result.isFilterEnable = false + + switch (entity) { + case DOCUMENT_ENTITY: + result.currentListingView = DocumentPreviewListItem + result.filterExplorerIcon = 'uk-icon-rz-folder-tree-mini' + result.trans.moreItems = 'moreDocuments' + result.isFilterEnable = true + break + case NODE_ENTITY: + result.currentListingView = NodePreviewItem + result.filterExplorerIcon = 'uk-icon-tags' + result.trans.moreItems = 'moreNodes' + result.isFilterEnable = true + break + case EXPLORER_PROVIDER_ENTITY: + case JOIN_ENTITY: + result.currentListingView = JoinPreviewItem + result.trans.moreItems = 'moreEntities' + break + case CUSTOM_FORM_ENTITY: + result.currentListingView = CustomFormPreviewItem + break + case NODE_TYPE_ENTITY: + result.currentListingView = NodeTypePreviewItem + result.trans.moreItems = 'moreNodeTypes' + break + case TAG_ENTITY: + result.currentListingView = TagPreviewItem + result.trans.moreItems = 'moreTags' + result.isFilterEnable = true + result.filterExplorerIcon = 'uk-icon-tags' + break + } + + return result + } + + static getListingView(entity) { + switch (entity) { + case DOCUMENT_ENTITY: + return DocumentPreviewListItem + case NODE_ENTITY: + return NodePreviewItem + case EXPLORER_PROVIDER_ENTITY: + case JOIN_ENTITY: + return JoinPreviewItem + case CUSTOM_FORM_ENTITY: + return CustomFormPreviewItem + case NODE_TYPE_ENTITY: + return NodeTypePreviewItem + case TAG_ENTITY: + return TagPreviewItem + } + } + + static getWidgetView(entity) { + switch (entity) { + case TAG_ENTITY: + return TagCreatorContainer + default: + return null + } + } +} diff --git a/lib/Rozier/src/Resources/app/filters/centralTruncate.js b/lib/Rozier/src/Resources/app/filters/centralTruncate.js new file mode 100644 index 00000000..52955cfd --- /dev/null +++ b/lib/Rozier/src/Resources/app/filters/centralTruncate.js @@ -0,0 +1,10 @@ +export default function centralTruncate(object, length, offset = 0, ellipsis = '[…]') { + if (object && object.length && object.length > length + ellipsis.length) { + let str1 = object.substr(0, Math.floor(length / 2) + Math.floor(offset / 2)) + let str2 = object.substr(Math.floor(length / 2) * -1 + Math.floor(offset / 2)) + + return str1 + ellipsis + str2 + } else { + return object + } +} diff --git a/lib/Rozier/src/Resources/app/filters/index.js b/lib/Rozier/src/Resources/app/filters/index.js new file mode 100644 index 00000000..8610726b --- /dev/null +++ b/lib/Rozier/src/Resources/app/filters/index.js @@ -0,0 +1,7 @@ +import centralTruncate from './centralTruncate' +import truncate from './truncate' + +export default { + centralTruncate, + truncate, +} diff --git a/lib/Rozier/src/Resources/app/filters/truncate.js b/lib/Rozier/src/Resources/app/filters/truncate.js new file mode 100644 index 00000000..0ae6f77a --- /dev/null +++ b/lib/Rozier/src/Resources/app/filters/truncate.js @@ -0,0 +1,9 @@ +export default function truncate(object, length, offset = 0, ellipsis = '…') { + if (object && object.length && object.length > length) { + let str1 = object.substr(0, length + offset) + + return str1 + ellipsis + } else { + return object + } +} diff --git a/lib/Rozier/src/Resources/app/fonts/fa-brands-400.woff b/lib/Rozier/src/Resources/app/fonts/fa-brands-400.woff new file mode 100644 index 0000000000000000000000000000000000000000..7bcc97eaeb7e793dacf0edbd6ba6f2c9345c0c13 GIT binary patch literal 87688 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 z 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%0D%8GQ!Ai?6y^fVUY%4hi>cI9O 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').insertAfter(el); + el.addClass('tag-editor-hidden-src') // hide original field + .data('options', o) // set data on hidden field + .on('focus.tag-editor', function(){ ed.click(); }); // simulate tabindex + + // add dummy item for min-height on empty editor + ed.append('
  •  
  • '); + + // markup for new tag + var new_tag = '
  •  '+o.delimiter[0]+'
  • '; + + // helper: update global data + function set_placeholder(){ + if (o.placeholder && !tag_list.length && !$('.deleted, .placeholder, input', ed).length) + ed.append('
  • '+o.placeholder+'
  • '); + } + + // helper: update global data + function update_globals(init){ + var old_tags = tag_list.toString(); + tag_list = $('.tag-editor-tag:not(.deleted)', ed).map(function(i, e) { + var val = $.trim($(this).hasClass('active') ? $(this).find('input').val() : $(e).text()); + if (val) return val; + }).get(); + ed.data('tags', tag_list); + el.val(tag_list.join(o.delimiter[0])); + // change callback except for plugin init + if (!init) if (old_tags != tag_list.toString()) o.onChange(el, ed, tag_list); + set_placeholder(); + } + + ed.click(function(e, closest_tag){ + var d, dist = 99999, loc; + + // do not create tag when user selects tags by text selection + if (window.getSelection && getSelection() != '') return; + + if (o.maxTags && ed.data('tags').length >= o.maxTags) { ed.find('input').blur(); return false; } + + blur_result = true + $('input:focus', ed).blur(); + if (!blur_result) return false; + blur_result = true + + // always remove placeholder on click + $('.placeholder', ed).remove(); + if (closest_tag && closest_tag.length) + loc = 'before'; + else { + // calculate tag closest to click position + $('.tag-editor-tag', ed).each(function(){ + var tag = $(this), to = tag.offset(), tag_x = to.left, tag_y = to.top; + if (e.pageY >= tag_y && e.pageY <= tag_y+tag.height()) { + if (e.pageX < tag_x) loc = 'before', d = tag_x - e.pageX; + else loc = 'after', d = e.pageX - tag_x - tag.width(); + if (d < dist) dist = d, closest_tag = tag; + } + }); + } + + if (loc == 'before') { + $(new_tag).insertBefore(closest_tag.closest('li')).find('.tag-editor-tag').click(); + } else if (loc == 'after') + $(new_tag).insertAfter(closest_tag.closest('li')).find('.tag-editor-tag').click(); + else // empty editor + $(new_tag).appendTo(ed).find('.tag-editor-tag').click(); + return false; + }); + + ed.on('click', '.tag-editor-delete', function(e){ + // delete icon is hidden when input is visible; place cursor near invisible delete icon on click + if ($(this).prev().hasClass('active')) { $(this).closest('li').find('input').caret(-1); return false; } + + var li = $(this).closest('li'), tag = li.find('.tag-editor-tag'); + if (o.beforeTagDelete(el, ed, tag_list, tag.text()) === false) return false; + tag.addClass('deleted').animate({width: 0}, o.animateDelete, function(){ li.remove(); set_placeholder(); }); + update_globals(); + return false; + }); + + // delete on right mouse click or ctrl+click + if (o.clickDelete) + ed.on('mousedown', '.tag-editor-tag', function(e){ + if (e.ctrlKey || e.which > 1) { + var li = $(this).closest('li'), tag = li.find('.tag-editor-tag'); + if (o.beforeTagDelete(el, ed, tag_list, tag.text()) === false) return false; + tag.addClass('deleted').animate({width: 0}, o.animateDelete, function(){ li.remove(); set_placeholder(); }); + update_globals(); + return false; + } + }); + + ed.on('click', '.tag-editor-tag', function(e){ + // delete on right click or ctrl+click -> exit + if (o.clickDelete && (e.ctrlKey || e.which > 1)) return false; + + if (!$(this).hasClass('active')) { + var tag = $(this).text(); + // guess cursor position in text input + var left_percent = Math.abs(($(this).offset().left - e.pageX)/$(this).width()), caret_pos = parseInt(tag.length*left_percent), + input = $(this).html('').addClass('active').find('input'); + input.data('old_tag', tag).tagEditorInput().focus().caret(caret_pos); + if (o.autocomplete) { + var aco = $.extend({}, o.autocomplete); + // extend user provided autocomplete select method + var ac_select = 'select' in aco ? o.autocomplete.select : ''; + aco.select = function(e, ui){ if (ac_select) ac_select(e, ui); setTimeout(function(){ + ed.trigger('click', [$('.active', ed).find('input').closest('li').next('li').find('.tag-editor-tag')]); + }, 20); }; + input.autocomplete(aco); + } + } + return false; + }); + + // helper: split into multiple tags, e.g. after paste + function split_cleanup(input){ + var li = input.closest('li'), sub_tags = input.val().replace(/ +/, ' ').split(o.dregex), + old_tag = input.data('old_tag'), old_tags = tag_list.slice(0), exceeded = false, cb_val; // copy tag_list + for (var i=0; i
     '+o.delimiter[0]+'
    '+escape(tag)+'
    '); + if (o.maxTags && old_tags.length >= o.maxTags) { exceeded = true; break; } + } + input.attr('maxlength', o.maxLength).removeData('old_tag').val('') + if (exceeded) input.blur(); else input.focus(); + update_globals(); + } + + ed.on('blur', 'input', function(e){ + e.stopPropagation(); + var input = $(this), old_tag = input.data('old_tag'), tag = $.trim(input.val().replace(/ +/, ' ').replace(o.dregex, o.delimiter[0])), cb_val; + if (!tag) { + if (old_tag && o.beforeTagDelete(el, ed, tag_list, old_tag) === false) { + input.val(old_tag).focus(); + blur_result = false; + update_globals(); + return; + } + try { input.closest('li').remove(); } catch(e){} + if (old_tag) update_globals(); + } + else if (tag.indexOf(o.delimiter[0])>=0) { split_cleanup(input); return; } + else if (tag != old_tag) { + if (o.forceLowercase) tag = tag.toLowerCase(); + cb_val = o.beforeTagSave(el, ed, tag_list, old_tag, tag); + tag = cb_val || tag; + if (cb_val === false) { + if (old_tag) { + input.val(old_tag).focus(); + blur_result = false; + update_globals(); + return; + } + try { input.closest('li').remove(); } catch(e){} + if (old_tag) update_globals(); + } + // remove duplicates + else if (o.removeDuplicates) + $('.tag-editor-tag:not(.active)', ed).each(function(){ if ($(this).text() == tag) $(this).closest('li').remove(); }); + } + input.parent().html(escape(tag)).removeClass('active'); + if (tag != old_tag) update_globals(); + set_placeholder(); + }); + + var pasted_content; + ed.on('paste', 'input', function(e){ + $(this).removeAttr('maxlength'); + pasted_content = $(this); + setTimeout(function(){ split_cleanup(pasted_content); }, 30); + }); + + // keypress delimiter + var inp; + ed.on('keypress', 'input', function(e){ + if (o.delimiter.indexOf(String.fromCharCode(e.which))>=0) { + inp = $(this); + setTimeout(function(){ split_cleanup(inp); }, 20); + } + }); + + ed.on('keydown', 'input', function(e){ + var $t = $(this); + + // left/up key + backspace key on empty field + if ((e.which == 37 || !o.autocomplete && e.which == 38) && !$t.caret() || e.which == 8 && !$t.val()) { + var prev_tag = $t.closest('li').prev('li').find('.tag-editor-tag'); + if (prev_tag.length) prev_tag.click().find('input').caret(-1); + else if ($t.val() && !(o.maxTags && ed.data('tags').length >= o.maxTags)) $(new_tag).insertBefore($t.closest('li')).find('.tag-editor-tag').click(); + return false; + } + // right/down key + else if ((e.which == 39 || !o.autocomplete && e.which == 40) && ($t.caret() == $t.val().length)) { + var next_tag = $t.closest('li').next('li').find('.tag-editor-tag'); + if (next_tag.length) next_tag.click().find('input').caret(0); + else if ($t.val()) ed.click(); + return false; + } + // tab key + else if (e.which == 9) { + // shift+tab + if (e.shiftKey) { + var prev_tag = $t.closest('li').prev('li').find('.tag-editor-tag'); + if (prev_tag.length) prev_tag.click().find('input').caret(0); + else if ($t.val() && !(o.maxTags && ed.data('tags').length >= o.maxTags)) $(new_tag).insertBefore($t.closest('li')).find('.tag-editor-tag').click(); + // allow tabbing to previous element + else { + el.attr('disabled', 'disabled'); + setTimeout(function(){ el.removeAttr('disabled'); }, 30); + return; + } + return false; + // tab + } else { + var next_tag = $t.closest('li').next('li').find('.tag-editor-tag'); + if (next_tag.length) next_tag.click().find('input').caret(0); + else if ($t.val()) ed.click(); + else return; // allow tabbing to next element + return false; + } + } + // del key + else if (e.which == 46 && (!$.trim($t.val()) || ($t.caret() == $t.val().length))) { + var next_tag = $t.closest('li').next('li').find('.tag-editor-tag'); + if (next_tag.length) next_tag.click().find('input').caret(0); + else if ($t.val()) ed.click(); + return false; + } + // enter key + else if (e.which == 13) { + ed.trigger('click', [$t.closest('li').next('li').find('.tag-editor-tag')]); + + // trigger blur if maxTags limit is reached + if (o.maxTags && ed.data('tags').length >= o.maxTags) ed.find('input').blur(); + + return false; + } + // pos1 + else if (e.which == 36 && !$t.caret()) ed.find('.tag-editor-tag').first().click(); + // end + else if (e.which == 35 && $t.caret() == $t.val().length) ed.find('.tag-editor-tag').last().click(); + // esc + else if (e.which == 27) { + $t.val($t.data('old_tag') ? $t.data('old_tag') : '').blur(); + return false; + } + }); + + // create initial tags + var tags = o.initialTags.length ? o.initialTags : el.val().split(o.dregex); + for (var i=0; i= o.maxTags) break; + var tag = $.trim(tags[i].replace(/ +/, ' ')); + if (tag) { + if (o.forceLowercase) tag = tag.toLowerCase(); + tag_list.push(tag); + ed.append('
  •  '+o.delimiter[0]+'
    '+escape(tag)+'
  • '); + } + } + update_globals(true); // true -> no onChange callback + + // init sortable + if (o.sortable && $.fn.sortable) ed.sortable({ + distance: 5, cancel: '.tag-editor-spacer, input', helper: 'clone', + update: function(){ update_globals(); } + }); + }); + }; + + $.fn.tagEditor.defaults = { + initialTags: [], + maxTags: 0, + maxLength: 50, + delimiter: ',;', + placeholder: '', + forceLowercase: true, + removeDuplicates: true, + clickDelete: false, + animateDelete: 175, + sortable: true, // jQuery UI sortable + autocomplete: null, // options dict for jQuery UI autocomplete + + // callbacks + onChange: function(){}, + beforeTagSave: function(){}, + beforeTagDelete: function(){} + }; +}(jQuery)); diff --git a/lib/Rozier/src/Resources/app/vendor/modernizr.custom.50380.js b/lib/Rozier/src/Resources/app/vendor/modernizr.custom.50380.js new file mode 100644 index 00000000..52d66c31 --- /dev/null +++ b/lib/Rozier/src/Resources/app/vendor/modernizr.custom.50380.js @@ -0,0 +1,4 @@ +/* Modernizr 2.8.3 (Custom Build) | MIT & BSD + * Build: http://modernizr.com/download/#-canvas-canvastext-hashchange-history-localstorage-postmessage-shiv-cssclasses-teststyles-testprop-testallprops-hasevent-prefixes-domprefixes-css_pointerevents-load + */ +;window.Modernizr=function(a,b,c){function A(a){j.cssText=a}function B(a,b){return A(m.join(a+";")+(b||""))}function C(a,b){return typeof a===b}function D(a,b){return!!~(""+a).indexOf(b)}function E(a,b){for(var d in a){var e=a[d];if(!D(e,"-")&&j[e]!==c)return b=="pfx"?e:!0}return!1}function F(a,b,d){for(var e in a){var f=b[a[e]];if(f!==c)return d===!1?a[e]:C(f,"function")?f.bind(d||b):f}return!1}function G(a,b,c){var d=a.charAt(0).toUpperCase()+a.slice(1),e=(a+" "+o.join(d+" ")+d).split(" ");return C(b,"string")||C(b,"undefined")?E(e,b):(e=(a+" "+p.join(d+" ")+d).split(" "),F(e,b,c))}var d="2.8.3",e={},f=!0,g=b.documentElement,h="modernizr",i=b.createElement(h),j=i.style,k,l={}.toString,m=" -webkit- -moz- -o- -ms- ".split(" "),n="Webkit Moz O ms",o=n.split(" "),p=n.toLowerCase().split(" "),q={},r={},s={},t=[],u=t.slice,v,w=function(a,c,d,e){var f,i,j,k,l=b.createElement("div"),m=b.body,n=m||b.createElement("body");if(parseInt(d,10))while(d--)j=b.createElement("div"),j.id=e?e[d]:h+(d+1),l.appendChild(j);return f=["­",'"].join(""),l.id=h,(m?l:n).innerHTML+=f,n.appendChild(l),m||(n.style.background="",n.style.overflow="hidden",k=g.style.overflow,g.style.overflow="hidden",g.appendChild(n)),i=c(l,a),m?l.parentNode.removeChild(l):(n.parentNode.removeChild(n),g.style.overflow=k),!!i},x=function(){function d(d,e){e=e||b.createElement(a[d]||"div"),d="on"+d;var f=d in e;return f||(e.setAttribute||(e=b.createElement("div")),e.setAttribute&&e.removeAttribute&&(e.setAttribute(d,""),f=C(e[d],"function"),C(e[d],"undefined")||(e[d]=c),e.removeAttribute(d))),e=null,f}var a={select:"input",change:"input",submit:"form",reset:"form",error:"img",load:"img",abort:"img"};return d}(),y={}.hasOwnProperty,z;!C(y,"undefined")&&!C(y.call,"undefined")?z=function(a,b){return y.call(a,b)}:z=function(a,b){return b in a&&C(a.constructor.prototype[b],"undefined")},Function.prototype.bind||(Function.prototype.bind=function(b){var c=this;if(typeof c!="function")throw new TypeError;var d=u.call(arguments,1),e=function(){if(this instanceof e){var a=function(){};a.prototype=c.prototype;var f=new a,g=c.apply(f,d.concat(u.call(arguments)));return Object(g)===g?g:f}return c.apply(b,d.concat(u.call(arguments)))};return e}),q.canvas=function(){var a=b.createElement("canvas");return!!a.getContext&&!!a.getContext("2d")},q.canvastext=function(){return!!e.canvas&&!!C(b.createElement("canvas").getContext("2d").fillText,"function")},q.postmessage=function(){return!!a.postMessage},q.hashchange=function(){return x("hashchange",a)&&(b.documentMode===c||b.documentMode>7)},q.history=function(){return!!a.history&&!!history.pushState},q.localstorage=function(){try{return localStorage.setItem(h,h),localStorage.removeItem(h),!0}catch(a){return!1}};for(var H in q)z(q,H)&&(v=H.toLowerCase(),e[v]=q[H](),t.push((e[v]?"":"no-")+v));return e.addTest=function(a,b){if(typeof a=="object")for(var d in a)z(a,d)&&e.addTest(d,a[d]);else{a=a.toLowerCase();if(e[a]!==c)return e;b=typeof b=="function"?b():b,typeof f!="undefined"&&f&&(g.className+=" "+(b?"":"no-")+a),e[a]=b}return e},A(""),i=k=null,function(a,b){function l(a,b){var c=a.createElement("p"),d=a.getElementsByTagName("head")[0]||a.documentElement;return c.innerHTML="x",d.insertBefore(c.lastChild,d.firstChild)}function m(){var a=s.elements;return typeof a=="string"?a.split(" "):a}function n(a){var b=j[a[h]];return b||(b={},i++,a[h]=i,j[i]=b),b}function o(a,c,d){c||(c=b);if(k)return c.createElement(a);d||(d=n(c));var g;return d.cache[a]?g=d.cache[a].cloneNode():f.test(a)?g=(d.cache[a]=d.createElem(a)).cloneNode():g=d.createElem(a),g.canHaveChildren&&!e.test(a)&&!g.tagUrn?d.frag.appendChild(g):g}function p(a,c){a||(a=b);if(k)return a.createDocumentFragment();c=c||n(a);var d=c.frag.cloneNode(),e=0,f=m(),g=f.length;for(;e",g="hidden"in a,k=a.childNodes.length==1||function(){b.createElement("a");var a=b.createDocumentFragment();return typeof a.cloneNode=="undefined"||typeof a.createDocumentFragment=="undefined"||typeof a.createElement=="undefined"}()}catch(c){g=!0,k=!0}})();var s={elements:d.elements||"abbr article aside audio bdi canvas data datalist details dialog figcaption figure footer header hgroup main mark meter nav output progress section summary template time video",version:c,shivCSS:d.shivCSS!==!1,supportsUnknownElements:k,shivMethods:d.shivMethods!==!1,type:"default",shivDocument:r,createElement:o,createDocumentFragment:p};a.html5=s,r(b)}(this,b),e._version=d,e._prefixes=m,e._domPrefixes=p,e._cssomPrefixes=o,e.hasEvent=x,e.testProp=function(a){return E([a])},e.testAllProps=G,e.testStyles=w,g.className=g.className.replace(/(^|\s)no-js(\s|$)/,"$1$2")+(f?" js "+t.join(" "):""),e}(this,this.document),function(a,b,c){function d(a){return"[object Function]"==o.call(a)}function e(a){return"string"==typeof a}function f(){}function g(a){return!a||"loaded"==a||"complete"==a||"uninitialized"==a}function h(){var a=p.shift();q=1,a?a.t?m(function(){("c"==a.t?B.injectCss:B.injectJs)(a.s,0,a.a,a.x,a.e,1)},0):(a(),h()):q=0}function i(a,c,d,e,f,i,j){function k(b){if(!o&&g(l.readyState)&&(u.r=o=1,!q&&h(),l.onload=l.onreadystatechange=null,b)){"img"!=a&&m(function(){t.removeChild(l)},50);for(var d in y[c])y[c].hasOwnProperty(d)&&y[c][d].onload()}}var j=j||B.errorTimeout,l=b.createElement(a),o=0,r=0,u={t:d,s:c,e:f,a:i,x:j};1===y[c]&&(r=1,y[c]=[]),"object"==a?l.data=c:(l.src=c,l.type=a),l.width=l.height="0",l.onerror=l.onload=l.onreadystatechange=function(){k.call(this,r)},p.splice(e,0,u),"img"!=a&&(r||2===y[c]?(t.insertBefore(l,s?null:n),m(k,j)):y[c].push(l))}function j(a,b,c,d,f){return q=0,b=b||"j",e(a)?i("c"==b?v:u,a,b,this.i++,c,d,f):(p.splice(this.i++,0,a),1==p.length&&h()),this}function k(){var a=B;return a.loader={load:j,i:0},a}var l=b.documentElement,m=a.setTimeout,n=b.getElementsByTagName("script")[0],o={}.toString,p=[],q=0,r="MozAppearance"in l.style,s=r&&!!b.createRange().compareNode,t=s?l:n.parentNode,l=a.opera&&"[object Opera]"==o.call(a.opera),l=!!b.attachEvent&&!l,u=r?"object":l?"script":"img",v=l?"script":u,w=Array.isArray||function(a){return"[object Array]"==o.call(a)},x=[],y={},z={timeout:function(a,b){return b.length&&(a.timeout=b[0]),a}},A,B;B=function(a){function b(a){var a=a.split("!"),b=x.length,c=a.pop(),d=a.length,c={url:c,origUrl:c,prefixes:a},e,f,g;for(f=0;f + */ + +/*! + * Vue.js v2.5.13 + * (c) 2014-2017 Evan You + * Released under the MIT License. + */ +(function (global, factory) { + typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() : + typeof define === 'function' && define.amd ? define(factory) : + (global.Vue = factory()); +}(this, (function () { 'use strict'; + + /* */ + + var emptyObject = Object.freeze({}); + +// these helpers produces better vm code in JS engines due to their +// explicitness and function inlining + function isUndef (v) { + return v === undefined || v === null + } + + function isDef (v) { + return v !== undefined && v !== null + } + + function isTrue (v) { + return v === true + } + + function isFalse (v) { + return v === false + } + + /** + * Check if value is primitive + */ + function isPrimitive (value) { + return ( + typeof value === 'string' || + typeof value === 'number' || + // $flow-disable-line + typeof value === 'symbol' || + typeof value === 'boolean' + ) + } + + /** + * Quick object check - this is primarily used to tell + * Objects from primitive values when we know the value + * is a JSON-compliant type. + */ + function isObject (obj) { + return obj !== null && typeof obj === 'object' + } + + /** + * Get the raw type string of a value e.g. [object Object] + */ + var _toString = Object.prototype.toString; + + function toRawType (value) { + return _toString.call(value).slice(8, -1) + } + + /** + * Strict object type check. Only returns true + * for plain JavaScript objects. + */ + function isPlainObject (obj) { + return _toString.call(obj) === '[object Object]' + } + + function isRegExp (v) { + return _toString.call(v) === '[object RegExp]' + } + + /** + * Check if val is a valid array index. + */ + function isValidArrayIndex (val) { + var n = parseFloat(String(val)); + return n >= 0 && Math.floor(n) === n && isFinite(val) + } + + /** + * Convert a value to a string that is actually rendered. + */ + function toString (val) { + return val == null + ? '' + : typeof val === 'object' + ? JSON.stringify(val, null, 2) + : String(val) + } + + /** + * Convert a input value to a number for persistence. + * If the conversion fails, return original string. + */ + function toNumber (val) { + var n = parseFloat(val); + return isNaN(n) ? val : n + } + + /** + * Make a map and return a function for checking if a key + * is in that map. + */ + function makeMap ( + str, + expectsLowerCase + ) { + var map = Object.create(null); + var list = str.split(','); + for (var i = 0; i < list.length; i++) { + map[list[i]] = true; + } + return expectsLowerCase + ? function (val) { return map[val.toLowerCase()]; } + : function (val) { return map[val]; } + } + + /** + * Check if a tag is a built-in tag. + */ + var isBuiltInTag = makeMap('slot,component', true); + + /** + * Check if a attribute is a reserved attribute. + */ + var isReservedAttribute = makeMap('key,ref,slot,slot-scope,is'); + + /** + * Remove an item from an array + */ + function remove (arr, item) { + if (arr.length) { + var index = arr.indexOf(item); + if (index > -1) { + return arr.splice(index, 1) + } + } + } + + /** + * Check whether the object has the property. + */ + var hasOwnProperty = Object.prototype.hasOwnProperty; + function hasOwn (obj, key) { + return hasOwnProperty.call(obj, key) + } + + /** + * Create a cached version of a pure function. + */ + function cached (fn) { + var cache = Object.create(null); + return (function cachedFn (str) { + var hit = cache[str]; + return hit || (cache[str] = fn(str)) + }) + } + + /** + * Camelize a hyphen-delimited string. + */ + var camelizeRE = /-(\w)/g; + var camelize = cached(function (str) { + return str.replace(camelizeRE, function (_, c) { return c ? c.toUpperCase() : ''; }) + }); + + /** + * Capitalize a string. + */ + var capitalize = cached(function (str) { + return str.charAt(0).toUpperCase() + str.slice(1) + }); + + /** + * Hyphenate a camelCase string. + */ + var hyphenateRE = /\B([A-Z])/g; + var hyphenate = cached(function (str) { + return str.replace(hyphenateRE, '-$1').toLowerCase() + }); + + /** + * Simple bind, faster than native + */ + function bind (fn, ctx) { + function boundFn (a) { + var l = arguments.length; + return l + ? l > 1 + ? fn.apply(ctx, arguments) + : fn.call(ctx, a) + : fn.call(ctx) + } + // record original fn length + boundFn._length = fn.length; + return boundFn + } + + /** + * Convert an Array-like object to a real Array. + */ + function toArray (list, start) { + start = start || 0; + var i = list.length - start; + var ret = new Array(i); + while (i--) { + ret[i] = list[i + start]; + } + return ret + } + + /** + * Mix properties into target object. + */ + function extend (to, _from) { + for (var key in _from) { + to[key] = _from[key]; + } + return to + } + + /** + * Merge an Array of Objects into a single Object. + */ + function toObject (arr) { + var res = {}; + for (var i = 0; i < arr.length; i++) { + if (arr[i]) { + extend(res, arr[i]); + } + } + return res + } + + /** + * Perform no operation. + * Stubbing args to make Flow happy without leaving useless transpiled code + * with ...rest (https://flow.org/blog/2017/05/07/Strict-Function-Call-Arity/) + */ + function noop (a, b, c) {} + + /** + * Always return false. + */ + var no = function (a, b, c) { return false; }; + + /** + * Return same value + */ + var identity = function (_) { return _; }; + + /** + * Generate a static keys string from compiler modules. + */ + function genStaticKeys (modules) { + return modules.reduce(function (keys, m) { + return keys.concat(m.staticKeys || []) + }, []).join(',') + } + + /** + * Check if two values are loosely equal - that is, + * if they are plain objects, do they have the same shape? + */ + function looseEqual (a, b) { + if (a === b) { return true } + var isObjectA = isObject(a); + var isObjectB = isObject(b); + if (isObjectA && isObjectB) { + try { + var isArrayA = Array.isArray(a); + var isArrayB = Array.isArray(b); + if (isArrayA && isArrayB) { + return a.length === b.length && a.every(function (e, i) { + return looseEqual(e, b[i]) + }) + } else if (!isArrayA && !isArrayB) { + var keysA = Object.keys(a); + var keysB = Object.keys(b); + return keysA.length === keysB.length && keysA.every(function (key) { + return looseEqual(a[key], b[key]) + }) + } else { + /* istanbul ignore next */ + return false + } + } catch (e) { + /* istanbul ignore next */ + return false + } + } else if (!isObjectA && !isObjectB) { + return String(a) === String(b) + } else { + return false + } + } + + function looseIndexOf (arr, val) { + for (var i = 0; i < arr.length; i++) { + if (looseEqual(arr[i], val)) { return i } + } + return -1 + } + + /** + * Ensure a function is called only once. + */ + function once (fn) { + var called = false; + return function () { + if (!called) { + called = true; + fn.apply(this, arguments); + } + } + } + + var SSR_ATTR = 'data-server-rendered'; + + var ASSET_TYPES = [ + 'component', + 'directive', + 'filter' + ]; + + var LIFECYCLE_HOOKS = [ + 'beforeCreate', + 'created', + 'beforeMount', + 'mounted', + 'beforeUpdate', + 'updated', + 'beforeDestroy', + 'destroyed', + 'activated', + 'deactivated', + 'errorCaptured' + ]; + + /* */ + + var config = ({ + /** + * Option merge strategies (used in core/util/options) + */ + // $flow-disable-line + optionMergeStrategies: Object.create(null), + + /** + * Whether to suppress warnings. + */ + silent: false, + + /** + * Show production mode tip message on boot? + */ + productionTip: "development" !== 'production', + + /** + * Whether to enable devtools + */ + devtools: "development" !== 'production', + + /** + * Whether to record perf + */ + performance: false, + + /** + * Error handler for watcher errors + */ + errorHandler: null, + + /** + * Warn handler for watcher warns + */ + warnHandler: null, + + /** + * Ignore certain custom elements + */ + ignoredElements: [], + + /** + * Custom user key aliases for v-on + */ + // $flow-disable-line + keyCodes: Object.create(null), + + /** + * Check if a tag is reserved so that it cannot be registered as a + * component. This is platform-dependent and may be overwritten. + */ + isReservedTag: no, + + /** + * Check if an attribute is reserved so that it cannot be used as a component + * prop. This is platform-dependent and may be overwritten. + */ + isReservedAttr: no, + + /** + * Check if a tag is an unknown element. + * Platform-dependent. + */ + isUnknownElement: no, + + /** + * Get the namespace of an element + */ + getTagNamespace: noop, + + /** + * Parse the real tag name for the specific platform. + */ + parsePlatformTagName: identity, + + /** + * Check if an attribute must be bound using property, e.g. value + * Platform-dependent. + */ + mustUseProp: no, + + /** + * Exposed for legacy reasons + */ + _lifecycleHooks: LIFECYCLE_HOOKS + }); + + /* */ + + /** + * Check if a string starts with $ or _ + */ + function isReserved (str) { + var c = (str + '').charCodeAt(0); + return c === 0x24 || c === 0x5F + } + + /** + * Define a property. + */ + function def (obj, key, val, enumerable) { + Object.defineProperty(obj, key, { + value: val, + enumerable: !!enumerable, + writable: true, + configurable: true + }); + } + + /** + * Parse simple path. + */ + var bailRE = /[^\w.$]/; + function parsePath (path) { + if (bailRE.test(path)) { + return + } + var segments = path.split('.'); + return function (obj) { + for (var i = 0; i < segments.length; i++) { + if (!obj) { return } + obj = obj[segments[i]]; + } + return obj + } + } + + /* */ + + +// can we use __proto__? + var hasProto = '__proto__' in {}; + +// Browser environment sniffing + var inBrowser = typeof window !== 'undefined'; + var inWeex = typeof WXEnvironment !== 'undefined' && !!WXEnvironment.platform; + var weexPlatform = inWeex && WXEnvironment.platform.toLowerCase(); + var UA = inBrowser && window.navigator.userAgent.toLowerCase(); + var isIE = UA && /msie|trident/.test(UA); + var isIE9 = UA && UA.indexOf('msie 9.0') > 0; + var isEdge = UA && UA.indexOf('edge/') > 0; + var isAndroid = (UA && UA.indexOf('android') > 0) || (weexPlatform === 'android'); + var isIOS = (UA && /iphone|ipad|ipod|ios/.test(UA)) || (weexPlatform === 'ios'); + var isChrome = UA && /chrome\/\d+/.test(UA) && !isEdge; + +// Firefox has a "watch" function on Object.prototype... + var nativeWatch = ({}).watch; + + var supportsPassive = false; + if (inBrowser) { + try { + var opts = {}; + Object.defineProperty(opts, 'passive', ({ + get: function get () { + /* istanbul ignore next */ + supportsPassive = true; + } + })); // https://github.com/facebook/flow/issues/285 + window.addEventListener('test-passive', null, opts); + } catch (e) {} + } + +// this needs to be lazy-evaled because vue may be required before +// vue-server-renderer can set VUE_ENV + var _isServer; + var isServerRendering = function () { + if (_isServer === undefined) { + /* istanbul ignore if */ + if (!inBrowser && typeof global !== 'undefined') { + // detect presence of vue-server-renderer and avoid + // Webpack shimming the process + _isServer = global['process'].env.VUE_ENV === 'server'; + } else { + _isServer = false; + } + } + return _isServer + }; + +// detect devtools + var devtools = inBrowser && window.__VUE_DEVTOOLS_GLOBAL_HOOK__; + + /* istanbul ignore next */ + function isNative (Ctor) { + return typeof Ctor === 'function' && /native code/.test(Ctor.toString()) + } + + var hasSymbol = + typeof Symbol !== 'undefined' && isNative(Symbol) && + typeof Reflect !== 'undefined' && isNative(Reflect.ownKeys); + + var _Set; + /* istanbul ignore if */ // $flow-disable-line + if (typeof Set !== 'undefined' && isNative(Set)) { + // use native Set when available. + _Set = Set; + } else { + // a non-standard Set polyfill that only works with primitive keys. + _Set = (function () { + function Set () { + this.set = Object.create(null); + } + Set.prototype.has = function has (key) { + return this.set[key] === true + }; + Set.prototype.add = function add (key) { + this.set[key] = true; + }; + Set.prototype.clear = function clear () { + this.set = Object.create(null); + }; + + return Set; + }()); + } + + /* */ + + var warn = noop; + var tip = noop; + var generateComponentTrace = (noop); // work around flow check + var formatComponentName = (noop); + + { + var hasConsole = typeof console !== 'undefined'; + var classifyRE = /(?:^|[-_])(\w)/g; + var classify = function (str) { return str + .replace(classifyRE, function (c) { return c.toUpperCase(); }) + .replace(/[-_]/g, ''); }; + + warn = function (msg, vm) { + var trace = vm ? generateComponentTrace(vm) : ''; + + if (config.warnHandler) { + config.warnHandler.call(null, msg, vm, trace); + } else if (hasConsole && (!config.silent)) { + console.error(("[Vue warn]: " + msg + trace)); + } + }; + + tip = function (msg, vm) { + if (hasConsole && (!config.silent)) { + console.warn("[Vue tip]: " + msg + ( + vm ? generateComponentTrace(vm) : '' + )); + } + }; + + formatComponentName = function (vm, includeFile) { + if (vm.$root === vm) { + return '' + } + var options = typeof vm === 'function' && vm.cid != null + ? vm.options + : vm._isVue + ? vm.$options || vm.constructor.options + : vm || {}; + var name = options.name || options._componentTag; + var file = options.__file; + if (!name && file) { + var match = file.match(/([^/\\]+)\.vue$/); + name = match && match[1]; + } + + return ( + (name ? ("<" + (classify(name)) + ">") : "") + + (file && includeFile !== false ? (" at " + file) : '') + ) + }; + + var repeat = function (str, n) { + var res = ''; + while (n) { + if (n % 2 === 1) { res += str; } + if (n > 1) { str += str; } + n >>= 1; + } + return res + }; + + generateComponentTrace = function (vm) { + if (vm._isVue && vm.$parent) { + var tree = []; + var currentRecursiveSequence = 0; + while (vm) { + if (tree.length > 0) { + var last = tree[tree.length - 1]; + if (last.constructor === vm.constructor) { + currentRecursiveSequence++; + vm = vm.$parent; + continue + } else if (currentRecursiveSequence > 0) { + tree[tree.length - 1] = [last, currentRecursiveSequence]; + currentRecursiveSequence = 0; + } + } + tree.push(vm); + vm = vm.$parent; + } + return '\n\nfound in\n\n' + tree + .map(function (vm, i) { return ("" + (i === 0 ? '---> ' : repeat(' ', 5 + i * 2)) + (Array.isArray(vm) + ? ((formatComponentName(vm[0])) + "... (" + (vm[1]) + " recursive calls)") + : formatComponentName(vm))); }) + .join('\n') + } else { + return ("\n\n(found in " + (formatComponentName(vm)) + ")") + } + }; + } + + /* */ + + + var uid = 0; + + /** + * A dep is an observable that can have multiple + * directives subscribing to it. + */ + var Dep = function Dep () { + this.id = uid++; + this.subs = []; + }; + + Dep.prototype.addSub = function addSub (sub) { + this.subs.push(sub); + }; + + Dep.prototype.removeSub = function removeSub (sub) { + remove(this.subs, sub); + }; + + Dep.prototype.depend = function depend () { + if (Dep.target) { + Dep.target.addDep(this); + } + }; + + Dep.prototype.notify = function notify () { + // stabilize the subscriber list first + var subs = this.subs.slice(); + for (var i = 0, l = subs.length; i < l; i++) { + subs[i].update(); + } + }; + +// the current target watcher being evaluated. +// this is globally unique because there could be only one +// watcher being evaluated at any time. + Dep.target = null; + var targetStack = []; + + function pushTarget (_target) { + if (Dep.target) { targetStack.push(Dep.target); } + Dep.target = _target; + } + + function popTarget () { + Dep.target = targetStack.pop(); + } + + /* */ + + var VNode = function VNode ( + tag, + data, + children, + text, + elm, + context, + componentOptions, + asyncFactory + ) { + this.tag = tag; + this.data = data; + this.children = children; + this.text = text; + this.elm = elm; + this.ns = undefined; + this.context = context; + this.fnContext = undefined; + this.fnOptions = undefined; + this.fnScopeId = undefined; + this.key = data && data.key; + this.componentOptions = componentOptions; + this.componentInstance = undefined; + this.parent = undefined; + this.raw = false; + this.isStatic = false; + this.isRootInsert = true; + this.isComment = false; + this.isCloned = false; + this.isOnce = false; + this.asyncFactory = asyncFactory; + this.asyncMeta = undefined; + this.isAsyncPlaceholder = false; + }; + + var prototypeAccessors = { child: { configurable: true } }; + +// DEPRECATED: alias for componentInstance for backwards compat. + /* istanbul ignore next */ + prototypeAccessors.child.get = function () { + return this.componentInstance + }; + + Object.defineProperties( VNode.prototype, prototypeAccessors ); + + var createEmptyVNode = function (text) { + if ( text === void 0 ) text = ''; + + var node = new VNode(); + node.text = text; + node.isComment = true; + return node + }; + + function createTextVNode (val) { + return new VNode(undefined, undefined, undefined, String(val)) + } + +// optimized shallow clone +// used for static nodes and slot nodes because they may be reused across +// multiple renders, cloning them avoids errors when DOM manipulations rely +// on their elm reference. + function cloneVNode (vnode, deep) { + var componentOptions = vnode.componentOptions; + var cloned = new VNode( + vnode.tag, + vnode.data, + vnode.children, + vnode.text, + vnode.elm, + vnode.context, + componentOptions, + vnode.asyncFactory + ); + cloned.ns = vnode.ns; + cloned.isStatic = vnode.isStatic; + cloned.key = vnode.key; + cloned.isComment = vnode.isComment; + cloned.fnContext = vnode.fnContext; + cloned.fnOptions = vnode.fnOptions; + cloned.fnScopeId = vnode.fnScopeId; + cloned.isCloned = true; + if (deep) { + if (vnode.children) { + cloned.children = cloneVNodes(vnode.children, true); + } + if (componentOptions && componentOptions.children) { + componentOptions.children = cloneVNodes(componentOptions.children, true); + } + } + return cloned + } + + function cloneVNodes (vnodes, deep) { + var len = vnodes.length; + var res = new Array(len); + for (var i = 0; i < len; i++) { + res[i] = cloneVNode(vnodes[i], deep); + } + return res + } + + /* + * not type checking this file because flow doesn't play well with + * dynamically accessing methods on Array prototype + */ + + var arrayProto = Array.prototype; + var arrayMethods = Object.create(arrayProto);[ + 'push', + 'pop', + 'shift', + 'unshift', + 'splice', + 'sort', + 'reverse' + ].forEach(function (method) { + // cache original method + var original = arrayProto[method]; + def(arrayMethods, method, function mutator () { + var args = [], len = arguments.length; + while ( len-- ) args[ len ] = arguments[ len ]; + + var result = original.apply(this, args); + var ob = this.__ob__; + var inserted; + switch (method) { + case 'push': + case 'unshift': + inserted = args; + break + case 'splice': + inserted = args.slice(2); + break + } + if (inserted) { ob.observeArray(inserted); } + // notify change + ob.dep.notify(); + return result + }); + }); + + /* */ + + var arrayKeys = Object.getOwnPropertyNames(arrayMethods); + + /** + * By default, when a reactive property is set, the new value is + * also converted to become reactive. However when passing down props, + * we don't want to force conversion because the value may be a nested value + * under a frozen data structure. Converting it would defeat the optimization. + */ + var observerState = { + shouldConvert: true + }; + + /** + * Observer class that are attached to each observed + * object. Once attached, the observer converts target + * object's property keys into getter/setters that + * collect dependencies and dispatches updates. + */ + var Observer = function Observer (value) { + this.value = value; + this.dep = new Dep(); + this.vmCount = 0; + def(value, '__ob__', this); + if (Array.isArray(value)) { + var augment = hasProto + ? protoAugment + : copyAugment; + augment(value, arrayMethods, arrayKeys); + this.observeArray(value); + } else { + this.walk(value); + } + }; + + /** + * Walk through each property and convert them into + * getter/setters. This method should only be called when + * value type is Object. + */ + Observer.prototype.walk = function walk (obj) { + var keys = Object.keys(obj); + for (var i = 0; i < keys.length; i++) { + defineReactive(obj, keys[i], obj[keys[i]]); + } + }; + + /** + * Observe a list of Array items. + */ + Observer.prototype.observeArray = function observeArray (items) { + for (var i = 0, l = items.length; i < l; i++) { + observe(items[i]); + } + }; + +// helpers + + /** + * Augment an target Object or Array by intercepting + * the prototype chain using __proto__ + */ + function protoAugment (target, src, keys) { + /* eslint-disable no-proto */ + target.__proto__ = src; + /* eslint-enable no-proto */ + } + + /** + * Augment an target Object or Array by defining + * hidden properties. + */ + /* istanbul ignore next */ + function copyAugment (target, src, keys) { + for (var i = 0, l = keys.length; i < l; i++) { + var key = keys[i]; + def(target, key, src[key]); + } + } + + /** + * Attempt to create an observer instance for a value, + * returns the new observer if successfully observed, + * or the existing observer if the value already has one. + */ + function observe (value, asRootData) { + if (!isObject(value) || value instanceof VNode) { + return + } + var ob; + if (hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) { + ob = value.__ob__; + } else if ( + observerState.shouldConvert && + !isServerRendering() && + (Array.isArray(value) || isPlainObject(value)) && + Object.isExtensible(value) && + !value._isVue + ) { + ob = new Observer(value); + } + if (asRootData && ob) { + ob.vmCount++; + } + return ob + } + + /** + * Define a reactive property on an Object. + */ + function defineReactive ( + obj, + key, + val, + customSetter, + shallow + ) { + var dep = new Dep(); + + var property = Object.getOwnPropertyDescriptor(obj, key); + if (property && property.configurable === false) { + return + } + + // cater for pre-defined getter/setters + var getter = property && property.get; + var setter = property && property.set; + + var childOb = !shallow && observe(val); + Object.defineProperty(obj, key, { + enumerable: true, + configurable: true, + get: function reactiveGetter () { + var value = getter ? getter.call(obj) : val; + if (Dep.target) { + dep.depend(); + if (childOb) { + childOb.dep.depend(); + if (Array.isArray(value)) { + dependArray(value); + } + } + } + return value + }, + set: function reactiveSetter (newVal) { + var value = getter ? getter.call(obj) : val; + /* eslint-disable no-self-compare */ + if (newVal === value || (newVal !== newVal && value !== value)) { + return + } + /* eslint-enable no-self-compare */ + if ("development" !== 'production' && customSetter) { + customSetter(); + } + if (setter) { + setter.call(obj, newVal); + } else { + val = newVal; + } + childOb = !shallow && observe(newVal); + dep.notify(); + } + }); + } + + /** + * Set a property on an object. Adds the new property and + * triggers change notification if the property doesn't + * already exist. + */ + function set (target, key, val) { + if (Array.isArray(target) && isValidArrayIndex(key)) { + target.length = Math.max(target.length, key); + target.splice(key, 1, val); + return val + } + if (key in target && !(key in Object.prototype)) { + target[key] = val; + return val + } + var ob = (target).__ob__; + if (target._isVue || (ob && ob.vmCount)) { + "development" !== 'production' && warn( + 'Avoid adding reactive properties to a Vue instance or its root $data ' + + 'at runtime - declare it upfront in the data option.' + ); + return val + } + if (!ob) { + target[key] = val; + return val + } + defineReactive(ob.value, key, val); + ob.dep.notify(); + return val + } + + /** + * Delete a property and trigger change if necessary. + */ + function del (target, key) { + if (Array.isArray(target) && isValidArrayIndex(key)) { + target.splice(key, 1); + return + } + var ob = (target).__ob__; + if (target._isVue || (ob && ob.vmCount)) { + "development" !== 'production' && warn( + 'Avoid deleting properties on a Vue instance or its root $data ' + + '- just set it to null.' + ); + return + } + if (!hasOwn(target, key)) { + return + } + delete target[key]; + if (!ob) { + return + } + ob.dep.notify(); + } + + /** + * Collect dependencies on array elements when the array is touched, since + * we cannot intercept array element access like property getters. + */ + function dependArray (value) { + for (var e = (void 0), i = 0, l = value.length; i < l; i++) { + e = value[i]; + e && e.__ob__ && e.__ob__.dep.depend(); + if (Array.isArray(e)) { + dependArray(e); + } + } + } + + /* */ + + /** + * Option overwriting strategies are functions that handle + * how to merge a parent option value and a child option + * value into the final value. + */ + var strats = config.optionMergeStrategies; + + /** + * Options with restrictions + */ + { + strats.el = strats.propsData = function (parent, child, vm, key) { + if (!vm) { + warn( + "option \"" + key + "\" can only be used during instance " + + 'creation with the `new` keyword.' + ); + } + return defaultStrat(parent, child) + }; + } + + /** + * Helper that recursively merges two data objects together. + */ + function mergeData (to, from) { + if (!from) { return to } + var key, toVal, fromVal; + var keys = Object.keys(from); + for (var i = 0; i < keys.length; i++) { + key = keys[i]; + toVal = to[key]; + fromVal = from[key]; + if (!hasOwn(to, key)) { + set(to, key, fromVal); + } else if (isPlainObject(toVal) && isPlainObject(fromVal)) { + mergeData(toVal, fromVal); + } + } + return to + } + + /** + * Data + */ + function mergeDataOrFn ( + parentVal, + childVal, + vm + ) { + if (!vm) { + // in a Vue.extend merge, both should be functions + if (!childVal) { + return parentVal + } + if (!parentVal) { + return childVal + } + // when parentVal & childVal are both present, + // we need to return a function that returns the + // merged result of both functions... no need to + // check if parentVal is a function here because + // it has to be a function to pass previous merges. + return function mergedDataFn () { + return mergeData( + typeof childVal === 'function' ? childVal.call(this, this) : childVal, + typeof parentVal === 'function' ? parentVal.call(this, this) : parentVal + ) + } + } else { + return function mergedInstanceDataFn () { + // instance merge + var instanceData = typeof childVal === 'function' + ? childVal.call(vm, vm) + : childVal; + var defaultData = typeof parentVal === 'function' + ? parentVal.call(vm, vm) + : parentVal; + if (instanceData) { + return mergeData(instanceData, defaultData) + } else { + return defaultData + } + } + } + } + + strats.data = function ( + parentVal, + childVal, + vm + ) { + if (!vm) { + if (childVal && typeof childVal !== 'function') { + "development" !== 'production' && warn( + 'The "data" option should be a function ' + + 'that returns a per-instance value in component ' + + 'definitions.', + vm + ); + + return parentVal + } + return mergeDataOrFn(parentVal, childVal) + } + + return mergeDataOrFn(parentVal, childVal, vm) + }; + + /** + * Hooks and props are merged as arrays. + */ + function mergeHook ( + parentVal, + childVal + ) { + return childVal + ? parentVal + ? parentVal.concat(childVal) + : Array.isArray(childVal) + ? childVal + : [childVal] + : parentVal + } + + LIFECYCLE_HOOKS.forEach(function (hook) { + strats[hook] = mergeHook; + }); + + /** + * Assets + * + * When a vm is present (instance creation), we need to do + * a three-way merge between constructor options, instance + * options and parent options. + */ + function mergeAssets ( + parentVal, + childVal, + vm, + key + ) { + var res = Object.create(parentVal || null); + if (childVal) { + "development" !== 'production' && assertObjectType(key, childVal, vm); + return extend(res, childVal) + } else { + return res + } + } + + ASSET_TYPES.forEach(function (type) { + strats[type + 's'] = mergeAssets; + }); + + /** + * Watchers. + * + * Watchers hashes should not overwrite one + * another, so we merge them as arrays. + */ + strats.watch = function ( + parentVal, + childVal, + vm, + key + ) { + // work around Firefox's Object.prototype.watch... + if (parentVal === nativeWatch) { parentVal = undefined; } + if (childVal === nativeWatch) { childVal = undefined; } + /* istanbul ignore if */ + if (!childVal) { return Object.create(parentVal || null) } + { + assertObjectType(key, childVal, vm); + } + if (!parentVal) { return childVal } + var ret = {}; + extend(ret, parentVal); + for (var key$1 in childVal) { + var parent = ret[key$1]; + var child = childVal[key$1]; + if (parent && !Array.isArray(parent)) { + parent = [parent]; + } + ret[key$1] = parent + ? parent.concat(child) + : Array.isArray(child) ? child : [child]; + } + return ret + }; + + /** + * Other object hashes. + */ + strats.props = + strats.methods = + strats.inject = + strats.computed = function ( + parentVal, + childVal, + vm, + key + ) { + if (childVal && "development" !== 'production') { + assertObjectType(key, childVal, vm); + } + if (!parentVal) { return childVal } + var ret = Object.create(null); + extend(ret, parentVal); + if (childVal) { extend(ret, childVal); } + return ret + }; + strats.provide = mergeDataOrFn; + + /** + * Default strategy. + */ + var defaultStrat = function (parentVal, childVal) { + return childVal === undefined + ? parentVal + : childVal + }; + + /** + * Validate component names + */ + function checkComponents (options) { + for (var key in options.components) { + validateComponentName(key); + } + } + + function validateComponentName (name) { + if (!/^[a-zA-Z][\w-]*$/.test(name)) { + warn( + 'Invalid component name: "' + name + '". Component names ' + + 'can only contain alphanumeric characters and the hyphen, ' + + 'and must start with a letter.' + ); + } + if (isBuiltInTag(name) || config.isReservedTag(name)) { + warn( + 'Do not use built-in or reserved HTML elements as component ' + + 'id: ' + name + ); + } + } + + /** + * Ensure all props option syntax are normalized into the + * Object-based format. + */ + function normalizeProps (options, vm) { + var props = options.props; + if (!props) { return } + var res = {}; + var i, val, name; + if (Array.isArray(props)) { + i = props.length; + while (i--) { + val = props[i]; + if (typeof val === 'string') { + name = camelize(val); + res[name] = { type: null }; + } else { + warn('props must be strings when using array syntax.'); + } + } + } else if (isPlainObject(props)) { + for (var key in props) { + val = props[key]; + name = camelize(key); + res[name] = isPlainObject(val) + ? val + : { type: val }; + } + } else { + warn( + "Invalid value for option \"props\": expected an Array or an Object, " + + "but got " + (toRawType(props)) + ".", + vm + ); + } + options.props = res; + } + + /** + * Normalize all injections into Object-based format + */ + function normalizeInject (options, vm) { + var inject = options.inject; + if (!inject) { return } + var normalized = options.inject = {}; + if (Array.isArray(inject)) { + for (var i = 0; i < inject.length; i++) { + normalized[inject[i]] = { from: inject[i] }; + } + } else if (isPlainObject(inject)) { + for (var key in inject) { + var val = inject[key]; + normalized[key] = isPlainObject(val) + ? extend({ from: key }, val) + : { from: val }; + } + } else { + warn( + "Invalid value for option \"inject\": expected an Array or an Object, " + + "but got " + (toRawType(inject)) + ".", + vm + ); + } + } + + /** + * Normalize raw function directives into object format. + */ + function normalizeDirectives (options) { + var dirs = options.directives; + if (dirs) { + for (var key in dirs) { + var def = dirs[key]; + if (typeof def === 'function') { + dirs[key] = { bind: def, update: def }; + } + } + } + } + + function assertObjectType (name, value, vm) { + if (!isPlainObject(value)) { + warn( + "Invalid value for option \"" + name + "\": expected an Object, " + + "but got " + (toRawType(value)) + ".", + vm + ); + } + } + + /** + * Merge two option objects into a new one. + * Core utility used in both instantiation and inheritance. + */ + function mergeOptions ( + parent, + child, + vm + ) { + { + checkComponents(child); + } + + if (typeof child === 'function') { + child = child.options; + } + + normalizeProps(child, vm); + normalizeInject(child, vm); + normalizeDirectives(child); + var extendsFrom = child.extends; + if (extendsFrom) { + parent = mergeOptions(parent, extendsFrom, vm); + } + if (child.mixins) { + for (var i = 0, l = child.mixins.length; i < l; i++) { + parent = mergeOptions(parent, child.mixins[i], vm); + } + } + var options = {}; + var key; + for (key in parent) { + mergeField(key); + } + for (key in child) { + if (!hasOwn(parent, key)) { + mergeField(key); + } + } + function mergeField (key) { + var strat = strats[key] || defaultStrat; + options[key] = strat(parent[key], child[key], vm, key); + } + return options + } + + /** + * Resolve an asset. + * This function is used because child instances need access + * to assets defined in its ancestor chain. + */ + function resolveAsset ( + options, + type, + id, + warnMissing + ) { + /* istanbul ignore if */ + if (typeof id !== 'string') { + return + } + var assets = options[type]; + // check local registration variations first + if (hasOwn(assets, id)) { return assets[id] } + var camelizedId = camelize(id); + if (hasOwn(assets, camelizedId)) { return assets[camelizedId] } + var PascalCaseId = capitalize(camelizedId); + if (hasOwn(assets, PascalCaseId)) { return assets[PascalCaseId] } + // fallback to prototype chain + var res = assets[id] || assets[camelizedId] || assets[PascalCaseId]; + if ("development" !== 'production' && warnMissing && !res) { + warn( + 'Failed to resolve ' + type.slice(0, -1) + ': ' + id, + options + ); + } + return res + } + + /* */ + + function validateProp ( + key, + propOptions, + propsData, + vm + ) { + var prop = propOptions[key]; + var absent = !hasOwn(propsData, key); + var value = propsData[key]; + // handle boolean props + if (isType(Boolean, prop.type)) { + if (absent && !hasOwn(prop, 'default')) { + value = false; + } else if (!isType(String, prop.type) && (value === '' || value === hyphenate(key))) { + value = true; + } + } + // check default value + if (value === undefined) { + value = getPropDefaultValue(vm, prop, key); + // since the default value is a fresh copy, + // make sure to observe it. + var prevShouldConvert = observerState.shouldConvert; + observerState.shouldConvert = true; + observe(value); + observerState.shouldConvert = prevShouldConvert; + } + { + assertProp(prop, key, value, vm, absent); + } + return value + } + + /** + * Get the default value of a prop. + */ + function getPropDefaultValue (vm, prop, key) { + // no default, return undefined + if (!hasOwn(prop, 'default')) { + return undefined + } + var def = prop.default; + // warn against non-factory defaults for Object & Array + if ("development" !== 'production' && isObject(def)) { + warn( + 'Invalid default value for prop "' + key + '": ' + + 'Props with type Object/Array must use a factory function ' + + 'to return the default value.', + vm + ); + } + // the raw prop value was also undefined from previous render, + // return previous default value to avoid unnecessary watcher trigger + if (vm && vm.$options.propsData && + vm.$options.propsData[key] === undefined && + vm._props[key] !== undefined + ) { + return vm._props[key] + } + // call factory function for non-Function types + // a value is Function if its prototype is function even across different execution context + return typeof def === 'function' && getType(prop.type) !== 'Function' + ? def.call(vm) + : def + } + + /** + * Assert whether a prop is valid. + */ + function assertProp ( + prop, + name, + value, + vm, + absent + ) { + if (prop.required && absent) { + warn( + 'Missing required prop: "' + name + '"', + vm + ); + return + } + if (value == null && !prop.required) { + return + } + var type = prop.type; + var valid = !type || type === true; + var expectedTypes = []; + if (type) { + if (!Array.isArray(type)) { + type = [type]; + } + for (var i = 0; i < type.length && !valid; i++) { + var assertedType = assertType(value, type[i]); + expectedTypes.push(assertedType.expectedType || ''); + valid = assertedType.valid; + } + } + if (!valid) { + warn( + "Invalid prop: type check failed for prop \"" + name + "\"." + + " Expected " + (expectedTypes.map(capitalize).join(', ')) + + ", got " + (toRawType(value)) + ".", + vm + ); + return + } + var validator = prop.validator; + if (validator) { + if (!validator(value)) { + warn( + 'Invalid prop: custom validator check failed for prop "' + name + '".', + vm + ); + } + } + } + + var simpleCheckRE = /^(String|Number|Boolean|Function|Symbol)$/; + + function assertType (value, type) { + var valid; + var expectedType = getType(type); + if (simpleCheckRE.test(expectedType)) { + var t = typeof value; + valid = t === expectedType.toLowerCase(); + // for primitive wrapper objects + if (!valid && t === 'object') { + valid = value instanceof type; + } + } else if (expectedType === 'Object') { + valid = isPlainObject(value); + } else if (expectedType === 'Array') { + valid = Array.isArray(value); + } else { + valid = value instanceof type; + } + return { + valid: valid, + expectedType: expectedType + } + } + + /** + * Use function string name to check built-in types, + * because a simple equality check will fail when running + * across different vms / iframes. + */ + function getType (fn) { + var match = fn && fn.toString().match(/^\s*function (\w+)/); + return match ? match[1] : '' + } + + function isType (type, fn) { + if (!Array.isArray(fn)) { + return getType(fn) === getType(type) + } + for (var i = 0, len = fn.length; i < len; i++) { + if (getType(fn[i]) === getType(type)) { + return true + } + } + /* istanbul ignore next */ + return false + } + + /* */ + + function handleError (err, vm, info) { + if (vm) { + var cur = vm; + while ((cur = cur.$parent)) { + var hooks = cur.$options.errorCaptured; + if (hooks) { + for (var i = 0; i < hooks.length; i++) { + try { + var capture = hooks[i].call(cur, err, vm, info) === false; + if (capture) { return } + } catch (e) { + globalHandleError(e, cur, 'errorCaptured hook'); + } + } + } + } + } + globalHandleError(err, vm, info); + } + + function globalHandleError (err, vm, info) { + if (config.errorHandler) { + try { + return config.errorHandler.call(null, err, vm, info) + } catch (e) { + logError(e, null, 'config.errorHandler'); + } + } + logError(err, vm, info); + } + + function logError (err, vm, info) { + { + warn(("Error in " + info + ": \"" + (err.toString()) + "\""), vm); + } + /* istanbul ignore else */ + if ((inBrowser || inWeex) && typeof console !== 'undefined') { + console.error(err); + } else { + throw err + } + } + + /* */ + /* globals MessageChannel */ + + var callbacks = []; + var pending = false; + + function flushCallbacks () { + pending = false; + var copies = callbacks.slice(0); + callbacks.length = 0; + for (var i = 0; i < copies.length; i++) { + copies[i](); + } + } + +// Here we have async deferring wrappers using both micro and macro tasks. +// In < 2.4 we used micro tasks everywhere, but there are some scenarios where +// micro tasks have too high a priority and fires in between supposedly +// sequential events (e.g. #4521, #6690) or even between bubbling of the same +// event (#6566). However, using macro tasks everywhere also has subtle problems +// when state is changed right before repaint (e.g. #6813, out-in transitions). +// Here we use micro task by default, but expose a way to force macro task when +// needed (e.g. in event handlers attached by v-on). + var microTimerFunc; + var macroTimerFunc; + var useMacroTask = false; + +// Determine (macro) Task defer implementation. +// Technically setImmediate should be the ideal choice, but it's only available +// in IE. The only polyfill that consistently queues the callback after all DOM +// events triggered in the same loop is by using MessageChannel. + /* istanbul ignore if */ + if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) { + macroTimerFunc = function () { + setImmediate(flushCallbacks); + }; + } else if (typeof MessageChannel !== 'undefined' && ( + isNative(MessageChannel) || + // PhantomJS + MessageChannel.toString() === '[object MessageChannelConstructor]' + )) { + var channel = new MessageChannel(); + var port = channel.port2; + channel.port1.onmessage = flushCallbacks; + macroTimerFunc = function () { + port.postMessage(1); + }; + } else { + /* istanbul ignore next */ + macroTimerFunc = function () { + setTimeout(flushCallbacks, 0); + }; + } + +// Determine MicroTask defer implementation. + /* istanbul ignore next, $flow-disable-line */ + if (typeof Promise !== 'undefined' && isNative(Promise)) { + var p = Promise.resolve(); + microTimerFunc = function () { + p.then(flushCallbacks); + // in problematic UIWebViews, Promise.then doesn't completely break, but + // it can get stuck in a weird state where callbacks are pushed into the + // microtask queue but the queue isn't being flushed, until the browser + // needs to do some other work, e.g. handle a timer. Therefore we can + // "force" the microtask queue to be flushed by adding an empty timer. + if (isIOS) { setTimeout(noop); } + }; + } else { + // fallback to macro + microTimerFunc = macroTimerFunc; + } + + /** + * Wrap a function so that if any code inside triggers state change, + * the changes are queued using a Task instead of a MicroTask. + */ + function withMacroTask (fn) { + return fn._withTask || (fn._withTask = function () { + useMacroTask = true; + var res = fn.apply(null, arguments); + useMacroTask = false; + return res + }) + } + + function nextTick (cb, ctx) { + var _resolve; + callbacks.push(function () { + if (cb) { + try { + cb.call(ctx); + } catch (e) { + handleError(e, ctx, 'nextTick'); + } + } else if (_resolve) { + _resolve(ctx); + } + }); + if (!pending) { + pending = true; + if (useMacroTask) { + macroTimerFunc(); + } else { + microTimerFunc(); + } + } + // $flow-disable-line + if (!cb && typeof Promise !== 'undefined') { + return new Promise(function (resolve) { + _resolve = resolve; + }) + } + } + + /* */ + + var mark; + var measure; + + { + var perf = inBrowser && window.performance; + /* istanbul ignore if */ + if ( + perf && + perf.mark && + perf.measure && + perf.clearMarks && + perf.clearMeasures + ) { + mark = function (tag) { return perf.mark(tag); }; + measure = function (name, startTag, endTag) { + perf.measure(name, startTag, endTag); + perf.clearMarks(startTag); + perf.clearMarks(endTag); + perf.clearMeasures(name); + }; + } + } + + /* not type checking this file because flow doesn't play well with Proxy */ + + var initProxy; + + { + var allowedGlobals = makeMap( + 'Infinity,undefined,NaN,isFinite,isNaN,' + + 'parseFloat,parseInt,decodeURI,decodeURIComponent,encodeURI,encodeURIComponent,' + + 'Math,Number,Date,Array,Object,Boolean,String,RegExp,Map,Set,JSON,Intl,' + + 'require' // for Webpack/Browserify + ); + + var warnNonPresent = function (target, key) { + warn( + "Property or method \"" + key + "\" is not defined on the instance but " + + 'referenced during render. Make sure that this property is reactive, ' + + 'either in the data option, or for class-based components, by ' + + 'initializing the property. ' + + 'See: https://vuejs.org/v2/guide/reactivity.html#Declaring-Reactive-Properties.', + target + ); + }; + + var hasProxy = + typeof Proxy !== 'undefined' && + Proxy.toString().match(/native code/); + + if (hasProxy) { + var isBuiltInModifier = makeMap('stop,prevent,self,ctrl,shift,alt,meta,exact'); + config.keyCodes = new Proxy(config.keyCodes, { + set: function set (target, key, value) { + if (isBuiltInModifier(key)) { + warn(("Avoid overwriting built-in modifier in config.keyCodes: ." + key)); + return false + } else { + target[key] = value; + return true + } + } + }); + } + + var hasHandler = { + has: function has (target, key) { + var has = key in target; + var isAllowed = allowedGlobals(key) || key.charAt(0) === '_'; + if (!has && !isAllowed) { + warnNonPresent(target, key); + } + return has || !isAllowed + } + }; + + var getHandler = { + get: function get (target, key) { + if (typeof key === 'string' && !(key in target)) { + warnNonPresent(target, key); + } + return target[key] + } + }; + + initProxy = function initProxy (vm) { + if (hasProxy) { + // determine which proxy handler to use + var options = vm.$options; + var handlers = options.render && options.render._withStripped + ? getHandler + : hasHandler; + vm._renderProxy = new Proxy(vm, handlers); + } else { + vm._renderProxy = vm; + } + }; + } + + /* */ + + var seenObjects = new _Set(); + + /** + * Recursively traverse an object to evoke all converted + * getters, so that every nested property inside the object + * is collected as a "deep" dependency. + */ + function traverse (val) { + _traverse(val, seenObjects); + seenObjects.clear(); + } + + function _traverse (val, seen) { + var i, keys; + var isA = Array.isArray(val); + if ((!isA && !isObject(val)) || Object.isFrozen(val)) { + return + } + if (val.__ob__) { + var depId = val.__ob__.dep.id; + if (seen.has(depId)) { + return + } + seen.add(depId); + } + if (isA) { + i = val.length; + while (i--) { _traverse(val[i], seen); } + } else { + keys = Object.keys(val); + i = keys.length; + while (i--) { _traverse(val[keys[i]], seen); } + } + } + + /* */ + + var normalizeEvent = cached(function (name) { + var passive = name.charAt(0) === '&'; + name = passive ? name.slice(1) : name; + var once$$1 = name.charAt(0) === '~'; // Prefixed last, checked first + name = once$$1 ? name.slice(1) : name; + var capture = name.charAt(0) === '!'; + name = capture ? name.slice(1) : name; + return { + name: name, + once: once$$1, + capture: capture, + passive: passive + } + }); + + function createFnInvoker (fns) { + function invoker () { + var arguments$1 = arguments; + + var fns = invoker.fns; + if (Array.isArray(fns)) { + var cloned = fns.slice(); + for (var i = 0; i < cloned.length; i++) { + cloned[i].apply(null, arguments$1); + } + } else { + // return handler return value for single handlers + return fns.apply(null, arguments) + } + } + invoker.fns = fns; + return invoker + } + + function updateListeners ( + on, + oldOn, + add, + remove$$1, + vm + ) { + var name, def, cur, old, event; + for (name in on) { + def = cur = on[name]; + old = oldOn[name]; + event = normalizeEvent(name); + /* istanbul ignore if */ + if (isUndef(cur)) { + "development" !== 'production' && warn( + "Invalid handler for event \"" + (event.name) + "\": got " + String(cur), + vm + ); + } else if (isUndef(old)) { + if (isUndef(cur.fns)) { + cur = on[name] = createFnInvoker(cur); + } + add(event.name, cur, event.once, event.capture, event.passive, event.params); + } else if (cur !== old) { + old.fns = cur; + on[name] = old; + } + } + for (name in oldOn) { + if (isUndef(on[name])) { + event = normalizeEvent(name); + remove$$1(event.name, oldOn[name], event.capture); + } + } + } + + /* */ + + function mergeVNodeHook (def, hookKey, hook) { + if (def instanceof VNode) { + def = def.data.hook || (def.data.hook = {}); + } + var invoker; + var oldHook = def[hookKey]; + + function wrappedHook () { + hook.apply(this, arguments); + // important: remove merged hook to ensure it's called only once + // and prevent memory leak + remove(invoker.fns, wrappedHook); + } + + if (isUndef(oldHook)) { + // no existing hook + invoker = createFnInvoker([wrappedHook]); + } else { + /* istanbul ignore if */ + if (isDef(oldHook.fns) && isTrue(oldHook.merged)) { + // already a merged invoker + invoker = oldHook; + invoker.fns.push(wrappedHook); + } else { + // existing plain hook + invoker = createFnInvoker([oldHook, wrappedHook]); + } + } + + invoker.merged = true; + def[hookKey] = invoker; + } + + /* */ + + function extractPropsFromVNodeData ( + data, + Ctor, + tag + ) { + // we are only extracting raw values here. + // validation and default values are handled in the child + // component itself. + var propOptions = Ctor.options.props; + if (isUndef(propOptions)) { + return + } + var res = {}; + var attrs = data.attrs; + var props = data.props; + if (isDef(attrs) || isDef(props)) { + for (var key in propOptions) { + var altKey = hyphenate(key); + { + var keyInLowerCase = key.toLowerCase(); + if ( + key !== keyInLowerCase && + attrs && hasOwn(attrs, keyInLowerCase) + ) { + tip( + "Prop \"" + keyInLowerCase + "\" is passed to component " + + (formatComponentName(tag || Ctor)) + ", but the declared prop name is" + + " \"" + key + "\". " + + "Note that HTML attributes are case-insensitive and camelCased " + + "props need to use their kebab-case equivalents when using in-DOM " + + "templates. You should probably use \"" + altKey + "\" instead of \"" + key + "\"." + ); + } + } + checkProp(res, props, key, altKey, true) || + checkProp(res, attrs, key, altKey, false); + } + } + return res + } + + function checkProp ( + res, + hash, + key, + altKey, + preserve + ) { + if (isDef(hash)) { + if (hasOwn(hash, key)) { + res[key] = hash[key]; + if (!preserve) { + delete hash[key]; + } + return true + } else if (hasOwn(hash, altKey)) { + res[key] = hash[altKey]; + if (!preserve) { + delete hash[altKey]; + } + return true + } + } + return false + } + + /* */ + +// The template compiler attempts to minimize the need for normalization by +// statically analyzing the template at compile time. +// +// For plain HTML markup, normalization can be completely skipped because the +// generated render function is guaranteed to return Array. There are +// two cases where extra normalization is needed: + +// 1. When the children contains components - because a functional component +// may return an Array instead of a single root. In this case, just a simple +// normalization is needed - if any child is an Array, we flatten the whole +// thing with Array.prototype.concat. It is guaranteed to be only 1-level deep +// because functional components already normalize their own children. + function simpleNormalizeChildren (children) { + for (var i = 0; i < children.length; i++) { + if (Array.isArray(children[i])) { + return Array.prototype.concat.apply([], children) + } + } + return children + } + +// 2. When the children contains constructs that always generated nested Arrays, +// e.g.