Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,57 @@ symfony console object-translation:import translations_fr.csv fr
The first argument is the path to the CSV file, and the second argument is the locale
of the translations in that file.

## Translation Management API

If you want to build a custom administration interface for managing translations in your application (e.g. using Symfony UX, React or plain PHP), the bundle provides a stable `TranslationManagerInterface`. This interface allows you to access translations programmatically without needing to interact directly with the database or internal services.

### `TranslationManager` Service

You can inject `TranslationManagerInterface` (an alias for `TranslationManager`) to perform CRUD operations, retrieve translation status, or invalidate the cache.

```php
use SymfonyCasts\ObjectTranslationBundle\TranslationManagerInterface;

class TranslationAdminController
{
public function edit(Product $product, TranslationManagerInterface $translationManager)
{
// Save a translation for a specific field and locale
$translationManager->saveTranslation($product, 'sk', 'name', 'Názov produktu');

// Save multiple translations at once
$translationManager->saveTranslations($product, 'sk', [
'name' => 'Názov produktu',
'description' => 'Dlhý popis produktu...',
]);

// Get all saved translations for a given entity and locale
$translations = $translationManager->findTranslations($product, 'sk'); // returns ['name' => '...', ...]

// Get translation status (which fields are filled and which are missing)
$status = $translationManager->getTranslationStatus($product, 'sk');
$status->getCompletionPercentage(); // e.g. 0.85 (85%)
$status->missingFields; // list of fields that are not yet translated

// Manually invalidate cache for this entity
$translationManager->invalidateCacheForEntity($product);
}
}
```

For automatic discovery of translatable entities, you can use:

```php
// Get list of all classes marked with #[Translatable] attribute
$types = $translationManager->getTranslatableTypes(); // returns list<TranslatableTypeInfo>

// Get fields for a specific class
$fields = $translationManager->getTranslatableFields(Product::class);

// Get all objects of a given class (useful for lists in administration)
$objects = $translationManager->getObjectsForType(Product::class);
```

## Translation Caching

For performance, translations are cached. By default, they use your `cache.app` pool
Expand Down
19 changes: 18 additions & 1 deletion config/services.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@

namespace Symfony\Component\DependencyInjection\Loader\Configurator;

use Symfony\Component\Cache\Adapter\NullAdapter;
use SymfonyCasts\ObjectTranslationBundle\Command\ObjectTranslationExportCommand;
use SymfonyCasts\ObjectTranslationBundle\Command\ObjectTranslationImportCommand;
use SymfonyCasts\ObjectTranslationBundle\Command\ObjectTranslationWarmupCommand;
use SymfonyCasts\ObjectTranslationBundle\ObjectTranslator;
use SymfonyCasts\ObjectTranslationBundle\TranslationManager;
use SymfonyCasts\ObjectTranslationBundle\TranslationManagerInterface;
use SymfonyCasts\ObjectTranslationBundle\TranslatableMappingManager;
use SymfonyCasts\ObjectTranslationBundle\Twig\ObjectTranslatorExtension;

Expand All @@ -15,18 +18,32 @@
->args([
service('translation.locale_switcher'),
param('kernel.default_locale'),
service('.symfonycasts.object_translator.mapping_manager')
service('.symfonycasts.object_translator.mapping_manager'),
service('.symfonycasts.object_translator.cache'),
])
->tag('twig.runtime')

->alias(ObjectTranslator::class, 'symfonycasts.object_translator')
->public()

->set('symfonycasts.object_translation_manager', TranslationManager::class)
->public()
->args([
service('.symfonycasts.object_translator.mapping_manager'),
service('doctrine'),
service('.symfonycasts.object_translator.cache'),
])

->alias(TranslationManagerInterface::class, 'symfonycasts.object_translation_manager')

->set('.symfonycasts.object_translator.mapping_manager', TranslatableMappingManager::class)
->args([
abstract_arg('Translation class'),
service('doctrine'),
])

->set('.symfonycasts.object_translator.cache', NullAdapter::class)

->set('.symfonycasts.object_translator.warmup_command', ObjectTranslationWarmupCommand::class)
->args([
service('symfonycasts.object_translator'),
Expand Down
14 changes: 14 additions & 0 deletions src/Dto/TranslatableTypeInfo.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

namespace SymfonyCasts\ObjectTranslationBundle\Dto;

final class TranslatableTypeInfo
{
public function __construct(
public readonly string $name, // alias from #[Translatable(name: '...')]
public readonly string $class, // FQCN
/** @var list<string> */
public readonly array $fields, // translatable property names
) {
}
}
33 changes: 33 additions & 0 deletions src/Dto/TranslationStatus.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

namespace SymfonyCasts\ObjectTranslationBundle\Dto;

final class TranslationStatus
{
public function __construct(
public readonly string $type,
public readonly string $id,
public readonly string $locale,
/** @var list<string> */
public readonly array $translatedFields,
/** @var list<string> */
public readonly array $missingFields,
) {
}

public function isFullyTranslated(): bool
{
return 0 === \count($this->missingFields);
}

public function getCompletionPercentage(): float
{
$total = \count($this->translatedFields) + \count($this->missingFields);

if (0 === $total) {
return 1.0;
}

return \count($this->translatedFields) / $total;
}
}
4 changes: 2 additions & 2 deletions src/ObjectTranslationBundle.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
use Symfony\Component\Config\Definition\Configurator\DefinitionConfigurator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
use Symfony\Component\DependencyInjection\Reference;
use Symfony\Component\HttpKernel\Bundle\AbstractBundle;
use SymfonyCasts\ObjectTranslationBundle\Model\Translation;

Expand Down Expand Up @@ -60,9 +59,10 @@ public function loadExtension(array $config, ContainerConfigurator $container, C
$container->import('../config/services.php');

$objectTranslatorDef = $builder->getDefinition('symfonycasts.object_translator');
$translationManagerDef = $builder->getDefinition('symfonycasts.object_translation_manager');

if ($config['cache']['enabled']) {
$objectTranslatorDef->setArgument(3, new Reference($config['cache']['pool']));
$builder->setAlias('.symfonycasts.object_translator.cache', $config['cache']['pool']);
$objectTranslatorDef->setArgument(4, $config['cache']['ttl']);
}

Expand Down
6 changes: 5 additions & 1 deletion src/ObjectTranslator.php
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,11 @@ private function translationsFor(object $object, string $locale, bool $forceRefr
"object_translation.{$locale}.{$type}.{$id}",
function (ItemInterface $item) use ($locale, $type, $id) {
if ($this->cache instanceof TagAwareCacheInterface) {
$item->tag(['object-translation', "object-translation-{$type}"]);
$item->tag([
'object-translation',
"object-translation-{$type}",
"object-translation-{$type}-{$id}",
]);
}

if ($this->cacheTtl) {
Expand Down
83 changes: 83 additions & 0 deletions src/TranslatableMappingManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace SymfonyCasts\ObjectTranslationBundle;

use Doctrine\ORM\EntityManagerInterface;
use Doctrine\Persistence\ManagerRegistry;
use Doctrine\Persistence\Proxy;
use SymfonyCasts\ObjectTranslationBundle\Mapping\Translatable;
Expand Down Expand Up @@ -123,4 +124,86 @@ public function upsert(string $type, string $id, string $locale, string $field,
$om->persist($translation);
$om->flush();
}

public function delete(string $type, string $id, string $locale, ?string $field = null): void
{
$om = $this->doctrine->getManagerForClass($this->translationClass);

if (!$om instanceof EntityManagerInterface) {
throw new \LogicException(sprintf('Object manager for class "%s" must be an instance of EntityManagerInterface', $this->translationClass));
}

$qb = $om->createQueryBuilder()
->delete($this->translationClass, 't')
->where('t.objectType = :type')
->andWhere('t.objectId = :id')
->andWhere('t.locale = :locale')
->setParameter('type', $type)
->setParameter('id', $id)
->setParameter('locale', $locale);

if ($field) {
$qb->andWhere('t.field = :field')
->setParameter('field', $field);
}

$qb->getQuery()->execute();
}

public function deleteForType(string $type, ?string $locale = null): void
{
$om = $this->doctrine->getManagerForClass($this->translationClass);

if (!$om instanceof EntityManagerInterface) {
throw new \LogicException(sprintf('Object manager for class "%s" must be an instance of EntityManagerInterface', $this->translationClass));
}

$qb = $om->createQueryBuilder()
->delete($this->translationClass, 't')
->where('t.objectType = :type')
->setParameter('type', $type);

if ($locale) {
$qb->andWhere('t.locale = :locale')
->setParameter('locale', $locale);
}

$qb->getQuery()->execute();
}

public function getTranslatableTypeMap(): array
{
$map = [];

foreach ($this->doctrine->getManagers() as $om) {
foreach ($om->getMetadataFactory()->getAllMetadata() as $metadata) {
$class = $metadata->getName();
$reflectionClass = new \ReflectionClass($class);
$attribute = $reflectionClass->getAttributes(Translatable::class)[0] ?? null;

if (!$attribute) {
continue;
}

$type = $attribute->newInstance()->name;
$map[$type] = $class;
}
}

return $map;
}

public function getTranslatableFieldsForClass(string $class): array
{
$reflectionClass = new \ReflectionClass($class);
$fields = [];

foreach ($reflectionClass->getProperties() as $property) {
if ($property->getAttributes(TranslatableProperty::class)) {
$fields[] = $property->getName();
}
}

return $fields;
}
}
Loading
Loading