├── src
├── Resources
│ ├── translations
│ │ └── .gitignore
│ └── config
│ │ ├── db_i18n.yaml
│ │ └── config.yaml
├── DbI18nBundle.php
├── Interfaces
│ ├── DbLoaderInterface.php
│ ├── TranslationRepositoryInterface.php
│ └── EntityInterface.php
├── DependencyInjection
│ ├── Configuration.php
│ └── DbI18nExtension.php
├── Repository
│ └── TranslationRepository.php
├── Loader
│ └── DbLoader.php
├── Entity
│ └── Translation.php
└── Command
│ └── MigrateToDatabaseCommand.php
├── .gitignore
├── psalm.xml
├── tests
├── doctrine.yaml
├── Kernel.php
├── Loader
│ └── DbLoaderTest.php
└── TranslationTest.php
├── phpunit.xml.dist
├── composer.json
└── README.md
/src/Resources/translations/.gitignore:
--------------------------------------------------------------------------------
1 | *
2 | !.gitignore
3 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | vendor
2 | composer.lock
3 | .phpunit.result.cache
4 | var/*
5 | tests/output
6 |
--------------------------------------------------------------------------------
/src/Resources/config/db_i18n.yaml:
--------------------------------------------------------------------------------
1 | db_i18n:
2 | entity: Creative\DbI18nBundle\Entity\Translation
3 | domain: db_messages
4 |
--------------------------------------------------------------------------------
/src/DbI18nBundle.php:
--------------------------------------------------------------------------------
1 |
2 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/src/Interfaces/TranslationRepositoryInterface.php:
--------------------------------------------------------------------------------
1 | getRootNode()
29 | ->children()
30 | ->scalarNode('entity')->defaultValue('Creative\\DbI18nBundle\\Entity\\Translation')->end()
31 | ->scalarNode('domain')->defaultValue('db_messages')->end()
32 | ;
33 |
34 | return $treeBuilder;
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/tests/Kernel.php:
--------------------------------------------------------------------------------
1 | load(__DIR__ . '/doctrine.yaml');
38 | $loader->load(__DIR__ . '/../src/Resources/config', 'glob');
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/src/Interfaces/EntityInterface.php:
--------------------------------------------------------------------------------
1 | load([
32 | * 'domain' => $domain,
33 | * 'locale' => $locale,
34 | * 'key' => $key,
35 | * 'translation' => $translation,
36 | * ]);
37 | * ```
38 | * and return valid entity for store in database.
39 | *
40 | * @param array $params
41 | *
42 | * @return EntityInterface
43 | */
44 | public function load(array $params): self;
45 | }
46 |
--------------------------------------------------------------------------------
/phpunit.xml.dist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 | tests
22 |
23 |
24 |
25 |
26 |
27 | src
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "creative/symfony-db-i18n-bundle",
3 | "type": "symfony-bundle",
4 | "description": "Allow store i18n-messages in database",
5 | "keywords": ["symfony", "i18n", "translations"],
6 | "license": "MIT",
7 | "authors": [
8 | {
9 | "name": "Andrew Zhdanovskih",
10 | "email": "azh@crtweb.ru"
11 | }
12 | ],
13 | "require": {
14 | "php": "^7.2||>=8.0",
15 | "doctrine/common": "^2.12|^3.1",
16 | "doctrine/doctrine-bundle": "^1.8|^2.0",
17 | "doctrine/orm": "^2.7",
18 | "doctrine/persistence": "^2",
19 | "ramsey/uuid": "^3.8|^4",
20 | "ramsey/uuid-doctrine": "^1.5",
21 | "symfony/config": "^4.1|^5",
22 | "symfony/dependency-injection": "^4.1|^5",
23 | "symfony/doctrine-bridge": "^4.1|^5",
24 | "symfony/finder": "^4.1|^5",
25 | "symfony/framework-bundle": "^4.1|^5",
26 | "symfony/polyfill-mbstring": "*",
27 | "symfony/translation": "^4.1|^5",
28 | "symfony/twig-bridge": "^4.1|^5",
29 | "symfony/twig-bundle": "^4.1|^5",
30 | "twig/twig": "^2.4|^3",
31 | "symfony/yaml": "^4.1|^5"
32 | },
33 | "require-dev": {
34 | "phpunit/phpunit": "^8.1",
35 | "symfony/console": "^4.1|^5",
36 | "symfony/css-selector": "^4.1|^5",
37 | "symfony/dom-crawler": "^4.1|^5",
38 | "symfony/phpunit-bridge": "^4.1|^5",
39 | "symfony/var-dumper": "^4.1|^5"
40 | },
41 | "extra": {
42 | "branch-alias": {
43 | "dev-master": "0.3-dev"
44 | }
45 | },
46 | "config": {
47 | "sort-packages": true
48 | },
49 | "autoload": {
50 | "psr-4": {
51 | "Creative\\DbI18nBundle\\": "src"
52 | }
53 | },
54 | "autoload-dev": {
55 | "psr-4": {
56 | "Creative\\DbI18nBundle\\Tests\\": "tests/"
57 | }
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/tests/Loader/DbLoaderTest.php:
--------------------------------------------------------------------------------
1 | get('translation.loader.db'));
23 | }
24 |
25 | protected function setUp(): void
26 | {
27 | self::bootKernel();
28 | $doctrine = self::$container->get('doctrine');
29 | /** @var EntityManager $em */
30 | $em = $doctrine->getManager();
31 |
32 | $schemaTool = new SchemaTool($em);
33 |
34 | $schemaTool->dropSchema([$em->getClassMetadata(Translation::class)]);
35 | $schemaTool->updateSchema([$em->getClassMetadata(Translation::class)]);
36 |
37 | $item = (new Translation())
38 | ->setDomain('db_messages')
39 | ->setKey('translatable.key')
40 | ->setLocale('en')
41 | ->setTranslation('This is a translation of key');
42 | $doctrine = self::$container->get('doctrine');
43 | $em = $doctrine->getManager();
44 | $em->persist($item);
45 | $em->flush();
46 |
47 | parent::setUp();
48 | }
49 |
50 | public function testLoadCatalogue(): void
51 | {
52 | $service = self::$container->get('translation.loader.db');
53 | $cat = $service->load(null, 'en', 'db_messages');
54 | self::assertInstanceOf(MessageCatalogue::class, $cat);
55 | self::assertSame('This is a translation of key', $cat->get('translatable.key', 'db_messages'));
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/src/Repository/TranslationRepository.php:
--------------------------------------------------------------------------------
1 |
37 | */
38 | public function findByDomainAndLocale(string $domain, string $locale)
39 | {
40 | return $this->createQueryBuilder('t', 't.key')
41 | ->where('t.domain = :domain')
42 | ->andWhere('t.locale = :locale')
43 | ->setParameter('domain', $domain)
44 | ->setParameter('locale', $locale)
45 | ->getQuery()
46 | ->getResult();
47 | }
48 |
49 | /**
50 | * @param string $locale
51 | *
52 | * @return ArrayCollection
53 | */
54 | public function findForUpdate(string $locale): ArrayCollection
55 | {
56 | $result = $this->createQueryBuilder('t')
57 | ->where('t.locale = :locale')
58 | ->setParameter('locale', $locale)
59 | ->orderBy('t.key', 'ASC')
60 | ->getQuery()->getResult();
61 |
62 | return new ArrayCollection($result);
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/src/DependencyInjection/DbI18nExtension.php:
--------------------------------------------------------------------------------
1 | processConfiguration($configuration, $configs);
34 |
35 | $container->setParameter('db_i18n.entity', $config['entity']);
36 | $container->setParameter('db_i18n.domain', $config['domain']);
37 | $container->setParameter('db_i18n.root_dir', __DIR__ . '/../');
38 | $container->setParameter('db_i18n.translation_dir', \dirname($container->getParameter('kernel.cache_dir')));
39 |
40 | $localeNames = [$container->getParameter('kernel.default_locale')];
41 | if ($container->hasParameter('locales') && is_array($locales = $container->getParameter('locales'))) {
42 | $localeNames = $locales;
43 | }
44 | $this->makeLocaleFiles($localeNames, $container->getParameter('db_i18n.translation_dir'), $container->getParameter('db_i18n.domain'));
45 |
46 | $loader = new YamlFileLoader($container, new FileLocator(__DIR__ . '/../Resources/config'));
47 | $loader->load('config.yaml');
48 | }
49 |
50 | /**
51 | * @param array $locales
52 | * @param string $targetDir
53 | * @param string $domain
54 | */
55 | protected function makeLocaleFiles(array $locales, string $targetDir, string $domain): void
56 | {
57 | foreach ($locales as $locale) {
58 | $path = $targetDir . '/' . $domain . '.' . $locale . '.db';
59 | if (!is_file($path)) {
60 | touch($path);
61 | }
62 | }
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | Sometimes You have to give the visual interface of i18n message CRUD for a customer. To do this, You need to have storage, which is not under version control and allowed from a form.
2 |
3 | # I18n messages stored in database
4 |
5 | With this bundle i18n messages stored in a database instead of files, then, you can implement web-interface to manage it.
6 |
7 | ## Installation
8 |
9 | ```bash
10 | composer require creative/symfony-db-i18n-bundle
11 | ```
12 |
13 | Bundle **has not** (yet) a flex auto-configurator. Add
14 |
15 | ```php
16 | Creative\DbI18nBundle\DbI18nBundle::class => ['all' => true],
17 | ```
18 |
19 | to you `config/bundles.php` file, and (optional) place the `db_i18n.yaml` with configuration (see below) file to your config directory.
20 |
21 | ## Some rules:
22 |
23 | - you application service container must have aa array `locales` parameter with possible application locales. For example:
24 | ```yaml
25 | # config/services.yaml
26 | parameters:
27 | locales: [ 'ru', 'en', 'de' ]
28 | ```
29 | - implementation of `Symfony\Contracts\Translation\TranslatorInterface` must have a `getCatalogue` method (usually, it have) for import messages from translation files to database.
30 | - You must define the default messages domain as `db_messages` in you views to use messages from database. For example:
31 | ```yaml
32 | # templates/main.html.twig
33 | {% trans_default_domain 'db_messages' %}
34 | ```
35 | - update you database schema after install this bundle — use `bin/console doctrine:schema:update` command or make migration for this.
36 |
37 | So, now you can load messages from old translation files to the database. Command
38 |
39 | ```bash
40 | bin/console creative:db-i18n:migrate translations/messages.en.yaml
41 | ```
42 |
43 | will import all messages from `[project root]/translations/messages.en.yaml`. You can set absolute path instead, nevermind, but file name must be compatible with Symfony localization files agreement — `..`.
44 |
45 | After (or instead of) that, make your forms/interfaces and add, change and so on with your messages.
46 |
47 | ## Defaults
48 |
49 | Default config is
50 |
51 | ```yaml
52 | # src/Resources/config/db_i18n.yaml
53 | db_i18n:
54 | entity: Creative\DbI18nBundle\Entity\Translation
55 | domain: db_messages
56 | ```
57 |
58 | Copy this wherever you want and modify.
59 |
60 | As you can see, the default messages domain is `db_messages`. If you want to override this and store default Symfony domain `messages` in a database, don't forget to remove (or rename) you `translations/messages..[yaml|csv|xlf]` file.
61 |
--------------------------------------------------------------------------------
/tests/TranslationTest.php:
--------------------------------------------------------------------------------
1 | entity = new Translation();
24 | }
25 |
26 | public function testGetId()
27 | {
28 | self::assertNull($this->entity->getId());
29 | }
30 |
31 | public function testSetDomain()
32 | {
33 | self::assertInstanceOf(Translation::class, $this->entity->setDomain('domain'));
34 | }
35 |
36 | public function testSetLocale()
37 | {
38 | self::assertInstanceOf(Translation::class, $this->entity->setLocale('ru'));
39 | }
40 |
41 | public function testGetKey()
42 | {
43 | self::assertNull($this->entity->getKey());
44 | }
45 |
46 | public function testGetDomain()
47 | {
48 | self::assertNull($this->entity->getDomain());
49 | $this->entity->setDomain('domain');
50 | self::assertEquals('domain', $this->entity->getDomain());
51 | }
52 |
53 | public function testGetTranslation()
54 | {
55 | self::assertNull($this->entity->getTranslation());
56 | $this->entity->setTranslation('translation');
57 | self::assertEquals('translation', $this->entity->getTranslation());
58 | }
59 |
60 | public function testGetLocale()
61 | {
62 | self::assertNull($this->entity->getLocale());
63 | $this->entity->setLocale('en');
64 | self::assertEquals('en', $this->entity->getLocale());
65 | }
66 |
67 | public function testSetTranslation()
68 | {
69 | self::assertInstanceOf(Translation::class, $this->entity->setTranslation('translation'));
70 | }
71 |
72 | public function testSetKey()
73 | {
74 | self::assertInstanceOf(Translation::class, $this->entity->setKey('key'));
75 | }
76 |
77 | public function testLoad()
78 | {
79 | $params = [
80 | 'locale' => 'en',
81 | 'key' => 'key',
82 | 'translation' => 'translation',
83 | 'domain' => 'domain',
84 | ];
85 |
86 | self::assertInstanceOf(Translation::class, $this->entity->load($params));
87 | self::assertEquals('en', $this->entity->getLocale());
88 | self::assertEquals('key', $this->entity->getKey());
89 | self::assertEquals('translation', $this->entity->getTranslation());
90 | self::assertEquals('domain', $this->entity->getDomain());
91 | }
92 | }
93 |
--------------------------------------------------------------------------------
/src/Loader/DbLoader.php:
--------------------------------------------------------------------------------
1 | doctrine = $doctrine;
45 | $this->entityClass = $container->get('db_i18n.entity');
46 | }
47 |
48 | /**
49 | * Loads a locale.
50 | *
51 | * @param mixed $resource A resource
52 | * @param string $locale A locale
53 | * @param string $domain The domain
54 | *
55 | * @return MessageCatalogue A MessageCatalogue instance
56 | *
57 | * @throws NotFoundResourceException when the resource cannot be found
58 | * @throws InvalidResourceException when the resource cannot be loaded
59 | */
60 | public function load($resource, string $locale, string $domain = 'messages'): MessageCatalogue
61 | {
62 | $messages = $this->getRepository()->findByDomainAndLocale($domain, $locale);
63 |
64 | $values = array_map(static function (EntityInterface $entity) {
65 | return $entity->getTranslation();
66 | }, $messages);
67 |
68 | $catalogue = new MessageCatalogue($locale, [
69 | $domain => $values,
70 | ]);
71 |
72 | return $catalogue;
73 | }
74 |
75 | /**
76 | * {@inheritDoc}
77 | */
78 | public function getRepository(): TranslationRepositoryInterface
79 | {
80 | $repository = $this->doctrine->getRepository($this->entityClass);
81 | if ($repository instanceof TranslationRepositoryInterface) {
82 | return $repository;
83 | }
84 |
85 | throw new \RuntimeException(\sprintf('Cannot load repository %s', TranslationRepositoryInterface::class));
86 | }
87 | }
88 |
--------------------------------------------------------------------------------
/src/Entity/Translation.php:
--------------------------------------------------------------------------------
1 | id;
49 | }
50 |
51 | /**
52 | * @return string|null
53 | */
54 | public function getDomain(): ?string
55 | {
56 | return $this->domain;
57 | }
58 |
59 | /**
60 | * @param string $domain
61 | *
62 | * @return Translation
63 | */
64 | public function setDomain(string $domain): self
65 | {
66 | $this->domain = $domain;
67 |
68 | return $this;
69 | }
70 |
71 | /**
72 | * @return string|null
73 | */
74 | public function getLocale(): ?string
75 | {
76 | return $this->locale;
77 | }
78 |
79 | /**
80 | * @param string $locale
81 | *
82 | * @return Translation
83 | */
84 | public function setLocale(string $locale): self
85 | {
86 | $this->locale = $locale;
87 |
88 | return $this;
89 | }
90 |
91 | /**
92 | * @return string|null
93 | */
94 | public function getKey(): ?string
95 | {
96 | return $this->key;
97 | }
98 |
99 | /**
100 | * @param string $key
101 | *
102 | * @return Translation
103 | */
104 | public function setKey(string $key): self
105 | {
106 | $this->key = $key;
107 |
108 | return $this;
109 | }
110 |
111 | /**
112 | * @return string|null
113 | */
114 | public function getTranslation(): ?string
115 | {
116 | return $this->translation;
117 | }
118 |
119 | /**
120 | * @param string $translation
121 | *
122 | * @return Translation
123 | */
124 | public function setTranslation(string $translation): self
125 | {
126 | $this->translation = $translation;
127 |
128 | return $this;
129 | }
130 |
131 | public function load(array $params): EntityInterface
132 | {
133 | foreach ($params as $key => $value) {
134 | $this->{$key} = $value;
135 | }
136 |
137 | return $this;
138 | }
139 | }
140 |
--------------------------------------------------------------------------------
/src/Command/MigrateToDatabaseCommand.php:
--------------------------------------------------------------------------------
1 | messages.ru.yaml
38 | messages.ru.xlf
39 | my_awesome_translations.en.xlf
40 | EOL;
41 |
42 | private const BATCH_SIZE = 100;
43 |
44 | /**
45 | * @var string
46 | */
47 | protected static $defaultName = 'creative:db-i18n:migrate';
48 |
49 | /**
50 | * @var ParameterBagInterface
51 | */
52 | private $container;
53 |
54 | /**
55 | * @var TranslatorInterface|Translator
56 | */
57 | private $translator;
58 |
59 | /**
60 | * @var string
61 | */
62 | private $entityClass;
63 |
64 | /**
65 | * @var ManagerRegistry
66 | */
67 | private $doctrine;
68 |
69 | /**
70 | * @var TranslationRepositoryInterface
71 | */
72 | private $translationEntityRepository;
73 |
74 | /**
75 | * MigrateToDatabaseCommand constructor.
76 | *
77 | * @param ParameterBagInterface $container
78 | * @param TranslatorInterface $translator
79 | * @param ManagerRegistry $doctrine
80 | * @param string|null $name
81 | */
82 | public function __construct(ParameterBagInterface $container, TranslatorInterface $translator, ManagerRegistry $doctrine, string $name = null)
83 | {
84 | parent::__construct($name);
85 | $this->container = $container;
86 | $this->translator = $translator;
87 | $this->entityClass = $this->container->get('db_i18n.entity');
88 | $this->doctrine = $doctrine;
89 | }
90 |
91 | /**
92 | * Configure options.
93 | */
94 | protected function configure(): void
95 | {
96 | $this->setDescription('Load data from translation file and pass it to database')
97 | ->addArgument('source-file', InputArgument::REQUIRED, 'File to import')
98 | ->setHelp(self::HELP)
99 | ;
100 | }
101 |
102 | /**
103 | * @param InputInterface $input
104 | * @param OutputInterface $output
105 | *
106 | * @return int|void|null
107 | */
108 | protected function execute(InputInterface $input, OutputInterface $output)
109 | {
110 | $this->translationEntityRepository = $this->doctrine->getRepository($this->entityClass);
111 |
112 | if (!method_exists($this->translator, 'getCatalogue')) {
113 | throw new RuntimeException('Translator service of application has no \'getCatalogue\' method');
114 | }
115 |
116 | if (!$this->container->has('locales') || !is_array($this->container->get('locales'))) {
117 | throw new RuntimeException('Application container must have a \'locales\' parameter, and this parameter must be an array');
118 | }
119 |
120 | $io = new SymfonyStyle($input, $output);
121 | $filePath = $this->locateFile($input->getArgument('source-file'));
122 |
123 | $locale = $this->getLocale(pathinfo($filePath, PATHINFO_FILENAME));
124 | $domain = trim(str_replace($locale, '', pathinfo($filePath, PATHINFO_FILENAME)), '.');
125 | $catalogue = $this->translator->getCatalogue($locale);
126 |
127 | $forExport = $catalogue->all($domain);
128 | $exported = $this->exportToDatabase($forExport, $locale, $this->container->get('db_i18n.domain'));
129 |
130 | $io->writeln(sprintf(
131 | 'Loaded form %s: %u messages, exported to database: %s',
132 | $filePath,
133 | count($forExport),
134 | $exported
135 | ));
136 |
137 | return 0;
138 | }
139 |
140 | /**
141 | * @param array $messages
142 | * @param string $locale
143 | * @param string $domain
144 | *
145 | * @return int
146 | */
147 | protected function exportToDatabase(array $messages, string $locale, string $domain): int
148 | {
149 | $count = 0;
150 | $i = 0;
151 | $em = $this->doctrine->getManager();
152 | foreach ($messages as $key => $value) {
153 | ++$count;
154 | ++$i;
155 | $em->persist($this->makeEntity($key, $value, $locale, $domain));
156 | if ($i > self::BATCH_SIZE) {
157 | $i = 0;
158 | $em->flush();
159 | }
160 | }
161 | $em->flush();
162 |
163 | return $count;
164 | }
165 |
166 | /**
167 | * @param string $key
168 | * @param string $translation
169 | * @param string $locale
170 | *
171 | * @return EntityInterface
172 | */
173 | protected function makeEntity(string $key, string $translation, string $locale, string $domain): EntityInterface
174 | {
175 | $entity = $this->checkEntityExists($locale, $key);
176 | $entity->load([
177 | 'domain' => $domain,
178 | 'locale' => $locale,
179 | 'key' => $key,
180 | 'translation' => $translation,
181 | ]);
182 |
183 | return $entity;
184 | }
185 |
186 | /**
187 | * @param string $locale
188 | * @param string $key
189 | *
190 | * @return EntityInterface|object
191 | */
192 | protected function checkEntityExists(string $locale, string $key): EntityInterface
193 | {
194 | $entity = $this->translationEntityRepository->findOneBy([
195 | 'locale' => $locale,
196 | 'key' => $key,
197 | ]);
198 |
199 | if ($entity === null) {
200 | $entity = new $this->entityClass();
201 | }
202 |
203 | return $entity;
204 | }
205 |
206 | /**
207 | * @param string $filename
208 | *
209 | * @return string
210 | */
211 | protected function getLocale(string $filename): ?string
212 | {
213 | $locales = $this->container->get('locales');
214 | $locale = null;
215 | foreach ($locales as $localeParam) {
216 | if (strpos($filename, $localeParam) !== false) {
217 | $locale = $localeParam;
218 | }
219 | }
220 |
221 | if ($locale === null) {
222 | throw new RuntimeException(sprintf('No one %s found in \'%s\'', implode(', ', $locales), $filename));
223 | }
224 |
225 | return $locale;
226 | }
227 |
228 | /**
229 | * @param string $path
230 | *
231 | * @return string
232 | */
233 | protected function locateFile(string $path): string
234 | {
235 | $realPath = null;
236 | if (strpos($path, '/') === 0) {
237 | $realPath = $path;
238 | } else {
239 | $realPath = $this->container->get('kernel.root_dir') . '/../' . $path;
240 | }
241 |
242 | if (!is_file($realPath) || !is_readable($realPath)) {
243 | throw new RuntimeException(sprintf('Unable to load %s file', $realPath));
244 | }
245 |
246 | return $realPath;
247 | }
248 | }
249 |
--------------------------------------------------------------------------------