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