1: <?php
2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14:
15: namespace Cake\ORM\Behavior;
16:
17: use ArrayObject;
18: use Cake\Collection\Collection;
19: use Cake\Datasource\EntityInterface;
20: use Cake\Datasource\QueryInterface;
21: use Cake\Event\Event;
22: use Cake\I18n\I18n;
23: use Cake\ORM\Behavior;
24: use Cake\ORM\Entity;
25: use Cake\ORM\Locator\LocatorAwareTrait;
26: use Cake\ORM\PropertyMarshalInterface;
27: use Cake\ORM\Query;
28: use Cake\ORM\Table;
29: use Cake\Utility\Inflector;
30:
31: 32: 33: 34: 35: 36: 37: 38: 39: 40: 41: 42:
43: class TranslateBehavior extends Behavior implements PropertyMarshalInterface
44: {
45: use LocatorAwareTrait;
46:
47: 48: 49: 50: 51:
52: protected $_table;
53:
54: 55: 56: 57: 58: 59:
60: protected $_locale;
61:
62: 63: 64: 65: 66:
67: protected $_translationTable;
68:
69: 70: 71: 72: 73: 74: 75:
76: protected $_defaultConfig = [
77: 'implementedFinders' => ['translations' => 'findTranslations'],
78: 'implementedMethods' => [
79: 'setLocale' => 'setLocale',
80: 'getLocale' => 'getLocale',
81: 'locale' => 'locale',
82: 'translationField' => 'translationField'
83: ],
84: 'fields' => [],
85: 'translationTable' => 'I18n',
86: 'defaultLocale' => '',
87: 'referenceName' => '',
88: 'allowEmptyTranslations' => true,
89: 'onlyTranslated' => false,
90: 'strategy' => 'subquery',
91: 'tableLocator' => null,
92: 'validator' => false
93: ];
94:
95: 96: 97: 98: 99: 100:
101: public function __construct(Table $table, array $config = [])
102: {
103: $config += [
104: 'defaultLocale' => I18n::getDefaultLocale(),
105: 'referenceName' => $this->_referenceName($table)
106: ];
107:
108: if (isset($config['tableLocator'])) {
109: $this->_tableLocator = $config['tableLocator'];
110: } else {
111: $this->_tableLocator = $table->associations()->getTableLocator();
112: }
113:
114: parent::__construct($table, $config);
115: }
116:
117: 118: 119: 120: 121: 122:
123: public function initialize(array $config)
124: {
125: $this->_translationTable = $this->getTableLocator()->get($this->_config['translationTable']);
126:
127: $this->setupFieldAssociations(
128: $this->_config['fields'],
129: $this->_config['translationTable'],
130: $this->_config['referenceName'],
131: $this->_config['strategy']
132: );
133: }
134:
135: 136: 137: 138: 139: 140: 141: 142: 143: 144: 145: 146: 147: 148:
149: public function setupFieldAssociations($fields, $table, $model, $strategy)
150: {
151: $targetAlias = $this->_translationTable->getAlias();
152: $alias = $this->_table->getAlias();
153: $filter = $this->_config['onlyTranslated'];
154: $tableLocator = $this->getTableLocator();
155:
156: foreach ($fields as $field) {
157: $name = $alias . '_' . $field . '_translation';
158:
159: if (!$tableLocator->exists($name)) {
160: $fieldTable = $tableLocator->get($name, [
161: 'className' => $table,
162: 'alias' => $name,
163: 'table' => $this->_translationTable->getTable()
164: ]);
165: } else {
166: $fieldTable = $tableLocator->get($name);
167: }
168:
169: $conditions = [
170: $name . '.model' => $model,
171: $name . '.field' => $field,
172: ];
173: if (!$this->_config['allowEmptyTranslations']) {
174: $conditions[$name . '.content !='] = '';
175: }
176:
177: $this->_table->hasOne($name, [
178: 'targetTable' => $fieldTable,
179: 'foreignKey' => 'foreign_key',
180: 'joinType' => $filter ? QueryInterface::JOIN_TYPE_INNER : QueryInterface::JOIN_TYPE_LEFT,
181: 'conditions' => $conditions,
182: 'propertyName' => $field . '_translation'
183: ]);
184: }
185:
186: $conditions = ["$targetAlias.model" => $model];
187: if (!$this->_config['allowEmptyTranslations']) {
188: $conditions["$targetAlias.content !="] = '';
189: }
190:
191: $this->_table->hasMany($targetAlias, [
192: 'className' => $table,
193: 'foreignKey' => 'foreign_key',
194: 'strategy' => $strategy,
195: 'conditions' => $conditions,
196: 'propertyName' => '_i18n',
197: 'dependent' => true
198: ]);
199: }
200:
201: 202: 203: 204: 205: 206: 207: 208: 209: 210:
211: public function beforeFind(Event $event, Query $query, $options)
212: {
213: $locale = $this->getLocale();
214:
215: if ($locale === $this->getConfig('defaultLocale')) {
216: return;
217: }
218:
219: $conditions = function ($field, $locale, $query, $select) {
220: return function ($q) use ($field, $locale, $query, $select) {
221:
222: $q->where([$q->getRepository()->aliasField('locale') => $locale]);
223:
224:
225: if ($query->isAutoFieldsEnabled() ||
226: in_array($field, $select, true) ||
227: in_array($this->_table->aliasField($field), $select, true)
228: ) {
229: $q->select(['id', 'content']);
230: }
231:
232: return $q;
233: };
234: };
235:
236: $contain = [];
237: $fields = $this->_config['fields'];
238: $alias = $this->_table->getAlias();
239: $select = $query->clause('select');
240:
241: $changeFilter = isset($options['filterByCurrentLocale']) &&
242: $options['filterByCurrentLocale'] !== $this->_config['onlyTranslated'];
243:
244: foreach ($fields as $field) {
245: $name = $alias . '_' . $field . '_translation';
246:
247: $contain[$name]['queryBuilder'] = $conditions(
248: $field,
249: $locale,
250: $query,
251: $select
252: );
253:
254: if ($changeFilter) {
255: $filter = $options['filterByCurrentLocale'] ? QueryInterface::JOIN_TYPE_INNER : QueryInterface::JOIN_TYPE_LEFT;
256: $contain[$name]['joinType'] = $filter;
257: }
258: }
259:
260: $query->contain($contain);
261: $query->formatResults(function ($results) use ($locale) {
262: return $this->_rowMapper($results, $locale);
263: }, $query::PREPEND);
264: }
265:
266: 267: 268: 269: 270: 271: 272: 273: 274:
275: public function beforeSave(Event $event, EntityInterface $entity, ArrayObject $options)
276: {
277: $locale = $entity->get('_locale') ?: $this->getLocale();
278: $newOptions = [$this->_translationTable->getAlias() => ['validate' => false]];
279: $options['associated'] = $newOptions + $options['associated'];
280:
281:
282:
283:
284: if ($this->_config['allowEmptyTranslations'] === false) {
285: $this->_unsetEmptyFields($entity);
286: }
287:
288: $this->_bundleTranslatedFields($entity);
289: $bundled = $entity->get('_i18n') ?: [];
290: $noBundled = count($bundled) === 0;
291:
292:
293:
294: if ($noBundled && $locale === $this->getConfig('defaultLocale')) {
295: return;
296: }
297:
298: $values = $entity->extract($this->_config['fields'], true);
299: $fields = array_keys($values);
300: $noFields = empty($fields);
301:
302:
303:
304:
305: if ($noFields && $noBundled || ($fields && $bundled)) {
306: return;
307: }
308:
309: $primaryKey = (array)$this->_table->getPrimaryKey();
310: $key = $entity->get(current($primaryKey));
311:
312:
313:
314:
315: if ($noFields && $bundled && !$key) {
316: foreach ($this->_config['fields'] as $field) {
317: $entity->setDirty($field, true);
318: }
319:
320: return;
321: }
322:
323: if ($noFields) {
324: return;
325: }
326:
327: $model = $this->_config['referenceName'];
328: $preexistent = $this->_translationTable->find()
329: ->select(['id', 'field'])
330: ->where([
331: 'field IN' => $fields,
332: 'locale' => $locale,
333: 'foreign_key' => $key,
334: 'model' => $model
335: ])
336: ->disableBufferedResults()
337: ->all()
338: ->indexBy('field');
339:
340: $modified = [];
341: foreach ($preexistent as $field => $translation) {
342: $translation->set('content', $values[$field]);
343: $modified[$field] = $translation;
344: }
345:
346: $new = array_diff_key($values, $modified);
347: foreach ($new as $field => $content) {
348: $new[$field] = new Entity(compact('locale', 'field', 'content', 'model'), [
349: 'useSetters' => false,
350: 'markNew' => true
351: ]);
352: }
353:
354: $entity->set('_i18n', array_merge($bundled, array_values($modified + $new)));
355: $entity->set('_locale', $locale, ['setter' => false]);
356: $entity->setDirty('_locale', false);
357:
358: foreach ($fields as $field) {
359: $entity->setDirty($field, false);
360: }
361: }
362:
363: 364: 365: 366: 367: 368: 369:
370: public function afterSave(Event $event, EntityInterface $entity)
371: {
372: $entity->unsetProperty('_i18n');
373: }
374:
375: 376: 377: 378: 379: 380: 381:
382: public function buildMarshalMap($marshaller, $map, $options)
383: {
384: if (isset($options['translations']) && !$options['translations']) {
385: return [];
386: }
387:
388: return [
389: '_translations' => function ($value, $entity) use ($marshaller, $options) {
390:
391: $translations = $entity->get('_translations');
392: foreach ($this->_config['fields'] as $field) {
393: $options['validate'] = $this->_config['validator'];
394: $errors = [];
395: if (!is_array($value)) {
396: return null;
397: }
398: foreach ($value as $language => $fields) {
399: if (!isset($translations[$language])) {
400: $translations[$language] = $this->_table->newEntity();
401: }
402: $marshaller->merge($translations[$language], $fields, $options);
403: if ((bool)$translations[$language]->getErrors()) {
404: $errors[$language] = $translations[$language]->getErrors();
405: }
406: }
407:
408:
409: $entity->setErrors($errors);
410: }
411:
412: return $translations;
413: }
414: ];
415: }
416:
417: 418: 419: 420: 421: 422: 423: 424: 425: 426: 427: 428: 429: 430: 431: 432: 433: 434: 435: 436:
437: public function setLocale($locale)
438: {
439: $this->_locale = $locale;
440:
441: return $this;
442: }
443:
444: 445: 446: 447: 448: 449: 450: 451: 452: 453:
454: public function getLocale()
455: {
456: return $this->_locale ?: I18n::getLocale();
457: }
458:
459: 460: 461: 462: 463: 464: 465: 466: 467:
468: public function locale($locale = null)
469: {
470: deprecationWarning(
471: get_called_class() . '::locale() is deprecated. ' .
472: 'Use setLocale()/getLocale() instead.'
473: );
474:
475: if ($locale !== null) {
476: $this->setLocale($locale);
477: }
478:
479: return $this->getLocale();
480: }
481:
482: 483: 484: 485: 486: 487: 488: 489: 490: 491:
492: public function translationField($field)
493: {
494: $table = $this->_table;
495: if ($this->getLocale() === $this->getConfig('defaultLocale')) {
496: return $table->aliasField($field);
497: }
498: $associationName = $table->getAlias() . '_' . $field . '_translation';
499:
500: if ($table->associations()->has($associationName)) {
501: return $associationName . '.content';
502: }
503:
504: return $table->aliasField($field);
505: }
506:
507: 508: 509: 510: 511: 512: 513: 514: 515: 516: 517: 518: 519: 520: 521: 522: 523: 524: 525: 526: 527: 528:
529: public function findTranslations(Query $query, array $options)
530: {
531: $locales = isset($options['locales']) ? $options['locales'] : [];
532: $targetAlias = $this->_translationTable->getAlias();
533:
534: return $query
535: ->contain([$targetAlias => function ($query) use ($locales, $targetAlias) {
536: if ($locales) {
537:
538: $query->where(["$targetAlias.locale IN" => $locales]);
539: }
540:
541: return $query;
542: }])
543: ->formatResults([$this, 'groupTranslations'], $query::PREPEND);
544: }
545:
546: 547: 548: 549: 550: 551: 552: 553: 554: 555: 556:
557: protected function _referenceName(Table $table)
558: {
559: $name = namespaceSplit(get_class($table));
560: $name = substr(end($name), 0, -5);
561: if (empty($name)) {
562: $name = $table->getTable() ?: $table->getAlias();
563: $name = Inflector::camelize($name);
564: }
565:
566: return $name;
567: }
568:
569: 570: 571: 572: 573: 574: 575: 576:
577: protected function _rowMapper($results, $locale)
578: {
579: return $results->map(function ($row) use ($locale) {
580: if ($row === null) {
581: return $row;
582: }
583: $hydrated = !is_array($row);
584:
585: foreach ($this->_config['fields'] as $field) {
586: $name = $field . '_translation';
587: $translation = isset($row[$name]) ? $row[$name] : null;
588:
589: if ($translation === null || $translation === false) {
590: unset($row[$name]);
591: continue;
592: }
593:
594: $content = isset($translation['content']) ? $translation['content'] : null;
595: if ($content !== null) {
596: $row[$field] = $content;
597: }
598:
599: unset($row[$name]);
600: }
601:
602: $row['_locale'] = $locale;
603: if ($hydrated) {
604:
605: $row->clean();
606: }
607:
608: return $row;
609: });
610: }
611:
612: 613: 614: 615: 616: 617: 618:
619: public function groupTranslations($results)
620: {
621: return $results->map(function ($row) {
622: if (!$row instanceof EntityInterface) {
623: return $row;
624: }
625: $translations = (array)$row->get('_i18n');
626: if (empty($translations) && $row->get('_translations')) {
627: return $row;
628: }
629: $grouped = new Collection($translations);
630:
631: $result = [];
632: foreach ($grouped->combine('field', 'content', 'locale') as $locale => $keys) {
633: $entityClass = $this->_table->getEntityClass();
634: $translation = new $entityClass($keys + ['locale' => $locale], [
635: 'markNew' => false,
636: 'useSetters' => false,
637: 'markClean' => true
638: ]);
639: $result[$locale] = $translation;
640: }
641:
642: $options = ['setter' => false, 'guard' => false];
643: $row->set('_translations', $result, $options);
644: unset($row['_i18n']);
645: $row->clean();
646:
647: return $row;
648: });
649: }
650:
651: 652: 653: 654: 655: 656: 657: 658:
659: protected function _bundleTranslatedFields($entity)
660: {
661: $translations = (array)$entity->get('_translations');
662:
663: if (empty($translations) && !$entity->isDirty('_translations')) {
664: return;
665: }
666:
667: $fields = $this->_config['fields'];
668: $primaryKey = (array)$this->_table->getPrimaryKey();
669: $key = $entity->get(current($primaryKey));
670: $find = [];
671: $contents = [];
672:
673: foreach ($translations as $lang => $translation) {
674: foreach ($fields as $field) {
675: if (!$translation->isDirty($field)) {
676: continue;
677: }
678: $find[] = ['locale' => $lang, 'field' => $field, 'foreign_key' => $key];
679: $contents[] = new Entity(['content' => $translation->get($field)], [
680: 'useSetters' => false
681: ]);
682: }
683: }
684:
685: if (empty($find)) {
686: return;
687: }
688:
689: $results = $this->_findExistingTranslations($find);
690:
691: foreach ($find as $i => $translation) {
692: if (!empty($results[$i])) {
693: $contents[$i]->set('id', $results[$i], ['setter' => false]);
694: $contents[$i]->isNew(false);
695: } else {
696: $translation['model'] = $this->_config['referenceName'];
697: $contents[$i]->set($translation, ['setter' => false, 'guard' => false]);
698: $contents[$i]->isNew(true);
699: }
700: }
701:
702: $entity->set('_i18n', $contents);
703: }
704:
705: 706: 707: 708: 709: 710: 711: 712:
713: protected function _unsetEmptyFields(EntityInterface $entity)
714: {
715: $translations = (array)$entity->get('_translations');
716: foreach ($translations as $locale => $translation) {
717: $fields = $translation->extract($this->_config['fields'], false);
718: foreach ($fields as $field => $value) {
719: if (strlen($value) === 0) {
720: $translation->unsetProperty($field);
721: }
722: }
723:
724: $translation = $translation->extract($this->_config['fields']);
725:
726:
727:
728: if (empty(array_filter($translation))) {
729: unset($entity->get('_translations')[$locale]);
730: }
731: }
732:
733:
734:
735: if (empty($entity->get('_translations'))) {
736: $entity->unsetProperty('_translations');
737: }
738: }
739:
740: 741: 742: 743: 744: 745: 746:
747: protected function _findExistingTranslations($ruleSet)
748: {
749: $association = $this->_table->getAssociation($this->_translationTable->getAlias());
750:
751: $query = $association->find()
752: ->select(['id', 'num' => 0])
753: ->where(current($ruleSet))
754: ->disableHydration()
755: ->disableBufferedResults();
756:
757: unset($ruleSet[0]);
758: foreach ($ruleSet as $i => $conditions) {
759: $q = $association->find()
760: ->select(['id', 'num' => $i])
761: ->where($conditions);
762: $query->unionAll($q);
763: }
764:
765: return $query->all()->combine('num', 'id')->toArray();
766: }
767: }
768: