Fixed typo causing fatal error
[project/i18n.git] / i18n_string / i18n_string.inc
1 <?php
2 /**
3 * @file
4 * API for internationalization strings
5 */
6
7 /**
8 * String object that contains source and translations.
9 *
10 * Note all database operations must go through textgroup object so we can switch storage at some point.
11 */
12 class i18n_string_object {
13 // Updated source string
14 public $string;
15 // Properties from locale source
16 public $lid;
17 public $source;
18 public $textgroup;
19 public $location;
20 public $context;
21 public $version;
22 // Properties from i18n_tring
23 public $type;
24 public $objectid;
25 public $property;
26 public $objectkey;
27 public $format;
28 // Properties from metadata
29 public $title;
30 // Array of translations to multiple languages
31 public $translations;
32 // Textgroup object
33 protected $_textgroup;
34
35 /**
36 * Class constructor
37 */
38 public function __construct($data = NULL) {
39 if ($data) {
40 $this->set_properties($data);
41 }
42 }
43 /**
44 * Get message parameters from context and string.
45 */
46 public function get_args() {
47 return array(
48 '%location' => $this->location,
49 '%textgroup' => $this->textgroup,
50 '%string' => ($string = $this->get_string()) ? $string : t('[empty string]'),
51 );
52 }
53 /**
54 * Set context properties
55 */
56 public function set_context($context) {
57 $parts = is_array($context) ? $context : explode(':', $context);
58 $this->context = is_array($context) ? implode(':', $context) : $context;
59 // Location will be the full string name
60 $this->location = $this->textgroup . ':' . $this->context;
61 $this->type = array_shift($parts);
62 $this->objectid = $parts ? array_shift($parts) : '';
63 $this->objectkey = (int)$this->objectid;
64 // Remaining elements glued again with ':'
65 $this->property = $parts ? implode(':', $parts) : '';
66 return $this;
67 }
68 /**
69 * Get string name including textgroup and context
70 */
71 public function get_name() {
72 return $this->textgroup . ':' . $this->type . ':' . $this->objectid . ':' . $this->property;
73 }
74 /**
75 * Get source string
76 */
77 public function get_string() {
78 if (isset($this->string)) {
79 return $this->string;
80 }
81 elseif (isset($this->source)) {
82 return $this->source;
83 }
84 elseif ($this->textgroup()->debug) {
85 return empty($this->lid) ? t('[Source not found]') : t('[String not found]');
86 }
87 else {
88 return '';
89 }
90 }
91 /**
92 * Set source string
93 *
94 * @param $string
95 * Plain string or array with 'string', 'format', etc...
96 */
97 public function set_string($string) {
98 if (is_array($string)) {
99 $this->string = isset($string['string']) ? $string['string'] : NULL;
100 if (isset($string['format'])) {
101 $this->format = $string['format'];
102 }
103 }
104 else {
105 $this->string = $string;
106 }
107 if (isset($string['title'])) {
108 $this->title = $string['title'];
109 }
110 return $this;
111 }
112 /**
113 * Get string title.
114 */
115 public function get_title() {
116 return isset($this->title) ? $this->title : t('String');
117 }
118 /**
119 * Get translation to language from string object
120 */
121 public function get_translation($langcode) {
122 if (!isset($this->translations[$langcode])) {
123 $translation = $this->textgroup()->load_translation($this, $langcode);
124 if ($translation && isset($translation->translation)) {
125 $this->set_translation($translation, $langcode);
126 }
127 else {
128 // No source, no translation
129 $this->translations[$langcode] = FALSE;
130 }
131 }
132 // Which doesn't mean we've got a translation, only that we've got the result cached
133 return $this->translations[$langcode];
134 }
135 /**
136 * Set translation for language
137 *
138 * @param $translation
139 * Translation object (from database) or string
140 */
141 public function set_translation($translation, $langcode = NULL) {
142 if (is_object($translation)) {
143 $langcode = $langcode ? $langcode : $translation->language;
144 $string = isset($translation->translation) ? $translation->translation : FALSE;
145 $this->set_properties($translation);
146 }
147 else {
148 $string = $translation;
149 }
150 $this->translations[$langcode] = $string;
151 return $this;
152 }
153
154 /**
155 * Format the resulting translation or the default string applying callbacks
156 *
157 * There's a hidden variable, 'i18n_string_debug', that when set to TRUE will display additional info
158 */
159 public function format_translation($langcode, $options = array()) {
160 $options += array('langcode' => $langcode, 'sanitize' => TRUE, 'sanitize default' => FALSE, 'cache' => FALSE, 'debug' => $this->textgroup()->debug);
161 if ($translation = $this->get_translation($langcode)) {
162 $string = $translation;
163 if (isset($options['filter'])) {
164 $string = call_user_func($options['filter'], $string);
165 }
166 }
167 else {
168 // Get default source string if no translation.
169 $string = $this->get_string();
170 $options['sanitize'] = $options['sanitize default'];
171 }
172 if (!empty($this->format)) {
173 $options += array('format' => $this->format);
174 }
175 elseif (empty($translation) && $options['sanitize']) {
176 // We are bout to display the source string without text format, force check_plain.
177 $string = check_plain($string);
178 }
179 // Add debug information if enabled
180 if ($options['debug']) {
181 $info = array($langcode, $this->textgroup, $this->context);
182 if (!empty($this->format)) {
183 $info[] = $this->format;
184 }
185 $options += array('suffix' => '');
186 $options['suffix'] .= ' [' . implode(':', $info) . ']';
187 }
188 // Finally, apply options, filters, callback, etc...
189 return i18n_string_format($string, $options);
190 }
191
192 /**
193 * Get source string provided a string object.
194 *
195 * @return
196 * String object if source exists.
197 */
198 public function get_source() {
199 // If already searched and not found we don't have a source,
200 if (isset($this->lid) && !$this->lid) {
201 return NULL;
202 }
203 elseif (!isset($this->lid) || !isset($this->source)) {
204 // We may have lid from loading a translation but not loaded the source yet.
205 if ($source = $this->textgroup()->load_source($this)) {
206 $this->set_properties($source, FALSE);
207 if (!isset($this->string)) {
208 $this->string = $source->source;
209 }
210 return $this;
211 }
212 else {
213 $this->lid = FALSE;
214 return NULL;
215 }
216 }
217 else {
218 return $this;
219 }
220 }
221
222 /**
223 * Set properties from object or array
224 *
225 * @param $properties
226 * Obejct or array of properties
227 * @param $set_null
228 * Whether to set null properties too
229 */
230 public function set_properties($properties, $set_null = TRUE) {
231 foreach ((array)$properties as $field => $value) {
232 if (property_exists($this, $field) && ($set_null || isset($value))) {
233 $this->$field = $value;
234 }
235 }
236 return $this;
237 }
238 /**
239 * Access textgroup object
240 */
241 protected function textgroup() {
242 if (!isset($this->_textgroup)) {
243 $this->_textgroup = i18n_string_textgroup($this->textgroup);
244 }
245 return $this->_textgroup;
246 }
247 /**
248 * Update this string.
249 */
250 public function update($options = array()) {
251 return $this->textgroup()->string_update($this, $options);
252 }
253 /**
254 * Delete this string.
255 */
256 public function remove($options = array()) {
257 return $this->textgroup()->string_remove($this, $options);
258 }
259 }
260
261 /**
262 * Textgroup handler for i18n_string API
263 */
264 class i18n_string_textgroup_default {
265 // Text group name
266 public $textgroup;
267 // Debug flag, set to true to print out more information.
268 public $debug;
269 // Cached or preloaded string objects
270 public $strings;
271 // Multiple translations search map
272 protected $cache_multiple;
273
274 /**
275 * Class constructor.
276 *
277 * There are to hidden variables to produce debugging information:
278 * - 'i18n_string_debug', generic for all text groups.
279 * - 'i18n_string_debug_TEXTGROUP', enable debug only for TEXTGROUP.
280 */
281 public function __construct($textgroup) {
282 $this->textgroup = $textgroup;
283 $this->debug = variable_get('i18n_string_debug', FALSE) || variable_get('i18n_string_debug_' . $textgroup, FALSE);
284 }
285 /**
286 * Build string object
287 *
288 * @param $context
289 * Context array or string
290 * @param $string string
291 * Current value for string source
292 */
293 public function build_string($context, $string = NULL) {
294 // First try to locate string on cache
295 $context = is_array($context) ? implode(':', $context) : $context;
296 if ($cached = $this->cache_get($context)) {
297 $i18nstring = $cached;
298 }
299 else {
300 $i18nstring = new i18n_string_object();
301 $i18nstring->textgroup = $this->textgroup;
302 $i18nstring->set_context($context);
303 $this->cache_set($context, $i18nstring);
304 }
305 if (isset($string)) {
306 $i18nstring->set_string($string);
307 }
308 return $i18nstring;
309 }
310 /**
311 * Add source string to the locale tables for translation.
312 *
313 * It will also add data into i18n_string table for faster retrieval and indexing of groups of strings.
314 * Some string context doesn't have a numeric oid (I.e. content types), it will be set to zero.
315 *
316 * This function checks for already existing string without context for this textgroup and updates it accordingly.
317 * It is intended for backwards compatibility, using already created strings.
318 *
319 * @param $i18nstring
320 * String object
321 * @param $format
322 * Text format, for strings that will go through some filter
323 * @return
324 * Update status.
325 */
326 protected function string_add($i18nstring, $options = array()) {
327 $options += array('watchdog' => TRUE);
328 // Default return status if nothing happens
329 $status = -1;
330 $source = NULL;
331 $location = $i18nstring->location;
332 // The string may not be allowed for translation depending on its format.
333 if (!$this->string_check($i18nstring, $options)) {
334 // The format may have changed and it's not allowed now, delete the source string
335 return $this->string_remove($i18nstring, $options);
336 }
337 elseif ($source = $i18nstring->get_source()) {
338 if ($source->source != $i18nstring->string || $source->location != $location) {
339 $i18nstring->location = $location;
340 // String has changed, mark translations for update
341 $status = $this->save_source($i18nstring);
342 db_update('locales_target')
343 ->fields(array('i18n_status' => I18N_STRING_STATUS_UPDATE))
344 ->condition('lid', $source->lid)
345 ->execute();
346 }
347 elseif (empty($source->version)) {
348 // When refreshing strings, we've done version = 0, update it
349 $this->save_source($i18nstring);
350 }
351 }
352 else {
353 // We don't have the source object, create it
354 $status = $this->save_source($i18nstring);
355 }
356 // Make sure we have i18n_string part, create or update
357 // This will also create the source object if doesn't exist
358 $this->save_string($i18nstring);
359
360 if ($options['watchdog']) {
361 switch ($status) {
362 case SAVED_UPDATED:
363 watchdog('i18n_string', 'Updated string %location for textgroup %textgroup: %string', $i18nstring->get_args());
364 break;
365 case SAVED_NEW:
366 watchdog('i18n_string', 'Created string %location for text group %textgroup: %string', $i18nstring->get_args());
367 break;
368 }
369 }
370 return $status;
371 }
372
373 /**
374 * Check if string is ok for translation
375 */
376 protected static function string_check($i18nstring, $options = array()) {
377 $options += array('messages' => FALSE, 'watchdog' => TRUE);
378 if (!empty($i18nstring->format) && !i18n_string_allowed_format($i18nstring->format)) {
379 // This format is not allowed, so we remove the string, in this case we produce a warning
380 drupal_set_message(t('The string %location for textgroup %textgroup is not allowed for translation because of its text format.', $i18nstring->get_args()), 'warning');
381 return FALSE;
382 }
383 else {
384 return TRUE;
385 }
386 }
387
388 /**
389 * Filter array of strings
390 *
391 * @param $filter
392 * Array of name value conditions.
393 */
394 protected static function string_filter($string_list, $filter) {
395 // Remove 'language' and '*' conditions.
396 if (isset($filter['language'])) {
397 unset($filter['language']);
398 }
399 while ($field = array_search('*', $filter)) {
400 unset($filter[$field]);
401 }
402 foreach ($string_list as $key => $string) {
403 foreach ($filter as $field => $value) {
404 if ($string->$field != $value) {
405 unset($string_list[$key]);
406 break;
407 }
408 }
409 }
410 return $string_list;
411 }
412
413 /**
414 * Build query for i18n_string table
415 */
416 protected static function string_query($context, $multiple = FALSE) {
417 // Search the database using lid if we've got it or textgroup, context otherwise
418 $query = db_select('i18n_string', 's')->fields('s');
419 if (!empty($context->lid)) {
420 $query->condition('s.lid', $context->lid);
421 }
422 else {
423 $query->condition('s.textgroup', $context->textgroup);
424 if (!$multiple) {
425 $query->condition('s.context', $context->context);
426 }
427 else {
428 // Query multiple strings
429 foreach (array('type', 'objectid', 'property') as $field) {
430 if (!empty($context->$field)) {
431 $query->condition('s.' . $field, $context->$field);
432 }
433 }
434 }
435 }
436 return $query;
437 }
438
439 /**
440 * Remove string object.
441 */
442 public function string_remove($i18nstring, $options = array()) {
443 $options += array('watchdog' => TRUE, 'messages' => $this->debug);
444 if ($source = $i18nstring->get_source()) {
445 db_delete('locales_target')->condition('lid', $source->lid)->execute();
446 db_delete('i18n_string')->condition('lid', $source->lid)->execute();
447 db_delete('locales_source')->condition('lid', $source->lid)->execute();
448 $this->cache_set($source->context, NULL);
449 if ($options['watchdog']) {
450 watchdog('i18n_string', 'Deleted string %location for text group %textgroup: %string', $i18nstring->get_args());
451 }
452 if ($options['messages']) {
453 drupal_set_message(t('Deleted string %location for tex tgroup %textgroup: %string', $i18nstring->get_args()));
454 }
455 return SAVED_DELETED;
456 }
457 else {
458 if($options['messages']) {
459 drupal_set_message(t('Cannot delete string, not found %location for text group %textgroup: %string', $i18nstring->get_args()));
460 }
461 return FALSE;
462 }
463 }
464
465 /**
466 * Translate string object
467 *
468 * @param $i18nstring
469 * String object
470 * @param $options
471 * Array with aditional options
472 */
473 protected function string_translate($i18nstring, $options = array()) {
474 $langcode = isset($options['langcode']) ? $options['langcode'] : i18n_langcode();
475 // Search for existing translation (result will be cached in this function call)
476 $i18nstring->get_translation($langcode);
477 return $i18nstring;
478 }
479
480 /**
481 * Update / create / remove string.
482 *
483 * @param $name
484 * String context.
485 * @pram $string
486 * New value of string for update/create. May be empty for removing.
487 * @param $format
488 * Text format, that must have been checked against allowed formats for translation
489 * @param $options
490 * Processing options, the ones used here are:
491 * - 'watchdog', whether to produce watchdog messages.
492 * - 'messages', whether to produce user messages.
493 * - 'check', whether to check string format and then update/delete if not allowed.
494 * @return status
495 * SAVED_UPDATED | SAVED_NEW | SAVED_DELETED
496 */
497 public function string_update($i18nstring, $options = array()) {
498 $options += array('watchdog' => TRUE, 'messages' => FALSE, 'check' => TRUE);
499 if (!$options['check'] || !$this->string_check($i18nstring, $options)) {
500 $status = $this->string_remove($i18nstring, $options);
501 }
502 elseif ($i18nstring->get_string()) {
503 $status = $this->string_add($i18nstring, $options);
504 }
505 else {
506 $status = $this->string_remove($i18nstring, $options);
507 }
508 if ($options['messages']) {
509 switch ($status) {
510 case SAVED_UPDATED:
511 drupal_set_message(t('Updated string %location for text group %textgroup: %string', $i18nstring->get_args()));
512 break;
513 case SAVED_NEW:
514 drupal_set_message(t('Created string %location for text group %textgroup: %string', $i18nstring->get_args()));
515 break;
516 }
517 }
518 if ($options['watchdog']) {
519 switch ($status) {
520 case SAVED_UPDATED:
521 watchdog('i18n_string', 'Updated string %location for text group %textgroup: %string', $i18nstring->get_args());
522 break;
523 case SAVED_NEW:
524 watchdog('i18n_string', 'Created string %location for text group %textgroup: %string', $i18nstring->get_args());
525 break;
526 }
527 }
528 return $status;
529 }
530
531 /**
532 * Set string object into cache
533 */
534 protected function cache_set($context, $string) {
535 $this->strings[$context] = $string;
536 }
537
538 /**
539 * Get translation from cache
540 */
541 protected function cache_get($context) {
542 return isset($this->strings[$context]) ? $this->strings[$context] : NULL;
543 }
544
545 /**
546 * Reset cache, needed for tests
547 */
548 public function cache_reset() {
549 $this->strings = array();
550 $this->string_format = array();
551 $this->translations = array();
552 }
553
554 /**
555 * Load string source from db
556 */
557 public static function load_source($i18nstring) {
558 // Search the database using lid if we've got it or textgroup, context otherwise
559 $query = db_select('locales_source', 's')->fields('s');
560 $query->leftJoin('i18n_string', 'i', 's.lid = i.lid');
561 $query->fields('i', array('format', 'objectid', 'type', 'property', 'objectindex'));
562 if (!empty($i18nstring->lid)) {
563 $query->condition('s.lid', $i18nstring->lid);
564 }
565 else {
566 $query->condition('s.textgroup', $i18nstring->textgroup);
567 $query->condition('s.context', $i18nstring->context);
568 }
569 // Speed up the query, we just need one row
570 return $query->range(0, 1)->execute()->fetchObject();
571 }
572
573 /**
574 * Load translation from db
575 *
576 * @todo Optimize when we've already got the source string
577 */
578 public static function load_translation($i18nstring, $langcode) {
579 // Search the database using lid if we've got it or textgroup, context otherwise
580 if (!empty($i18nstring->lid)) {
581 // We've alreay got lid, we just need translation data
582 $query = db_select('locales_target', 't');
583 $query->condition('t.lid', $i18nstring->lid);
584 }
585 else {
586 // Still don't have lid, load string properties too
587 $query = db_select('i18n_string', 's')->fields('s');
588 $query->leftJoin('locales_target', 't', 's.lid = t.lid');
589 $query->condition('s.textgroup', $i18nstring->textgroup);
590 $query->condition('s.context', $i18nstring->context);
591 }
592 // Add translation fields
593 $query->fields('t', array('translation', 'i18n_status'));
594 $query->condition('t.language', $langcode);
595 // Speed up the query, we just need one row
596 $query->range(0, 1);
597 return $query->execute()->fetchObject();
598 }
599
600 /**
601 * Save / update string object
602 *
603 * There seems to be a race condition sometimes so skip errors, #277711
604 *
605 * @param $string
606 * Full string object to be saved
607 * @param $source
608 * Source string object
609 */
610 protected function save_string($string, $update = FALSE) {
611 if (!$string->get_source()) {
612 // Create source string so we get an lid
613 $this->save_source($string);
614 }
615 if (!isset($string->objectkey)) {
616 $string->objectkey = (int)$string->objectid;
617 }
618 if (!isset($string->format)) {
619 $string->format = '';
620 }
621 $status = db_merge('i18n_string')
622 ->key(array('lid' => $string->lid))
623 ->fields(array(
624 'textgroup' => $string->textgroup,
625 'context' => $string->context,
626 'objectid' => $string->objectid,
627 'type' => $string->type,
628 'property' => $string->property,
629 'objectindex' => $string->objectkey,
630 'format' => $string->format,
631 ))
632 ->execute();
633 return $status;
634 }
635
636 /**
637 * Save translation to the db
638 *
639 * @param $string
640 * Full string object with translation data (language, translation)
641 */
642 protected function save_translation($string, $langcode) {
643 db_merge('locales_target')
644 ->key(array('lid' => $string->lid, 'language' => $langcode))
645 ->fields(array('translation' => $string->get_translation($langcode)))
646 ->execute();
647 }
648
649 /**
650 * Save source string (create / update)
651 */
652 protected static function save_source($source) {
653 if (isset($source->string)) {
654 $source->source = $source->string;
655 }
656 if (empty($source->version)) {
657 $source->version = 1;
658 }
659 return drupal_write_record('locales_source', $source, !empty($source->lid) ? 'lid' : array());
660 }
661
662 /**
663 * Remove source and translations for user defined string.
664 *
665 * Though for most strings the 'name' or 'string id' uniquely identifies that string,
666 * there are some exceptions (like profile categories) for which we need to use the
667 * source string itself as a search key.
668 *
669 * @param $context
670 * Textgroup and location glued with ':'.
671 * @param $string
672 * Optional source string (string in default language).
673 */
674 public function context_remove($context, $string = NULL, $options = array()) {
675 $options += array('messages' => $this->debug);
676 $i18nstring = $this->build_string($context, $string);
677 $status = $this->string_remove($i18nstring, $options);
678
679 return $this;
680 }
681
682 /**
683 * Translate source string
684 */
685 public function context_translate($context, $string, $options = array()) {
686 $i18nstring = $this->build_string($context, $string);
687 return $this->string_translate($i18nstring, $options);
688 }
689
690 /**
691 * Update / create translation source for user defined strings.
692 *
693 * @param $name
694 * Textgroup and location glued with ':'.
695 * @param $string
696 * Source string in default language. Default language may or may not be English.
697 * @param $options
698 * Array with additional options:
699 * - 'format', String format if the string has text format.
700 * - 'messages', Whether to print out status messages.
701 * - 'check', whether to check string format and then update/delete if not allowed.
702 */
703 public function context_update($context, $string, $options = array()) {
704 $options += array('format' => FALSE, 'messages' => $this->debug, 'watchdog' => TRUE, 'check' => TRUE);
705 $i18nstring = $this->build_string($context, $string);
706 $i18nstring->format = $options['format'];
707 $this->string_update($i18nstring, $options);
708 return $this;
709 }
710
711 /**
712 * Build combinations of an array of arrays respecting keys.
713 *
714 * Example:
715 * array(array(a,b), array(1,2)) will translate into
716 * array(a,1), array(a,2), array(b,1), array(b,2)
717 */
718 protected static function multiple_combine($properties) {
719 $combinations = array();
720 // Get first key, value. We need to make sure the array pointer is reset.
721 $value = reset($properties);
722 $key = key($properties);
723 array_shift($properties);
724 $values = is_array($value) ? $value : array($value);
725 foreach ($values as $value) {
726 if ($properties) {
727 foreach (self::multiple_combine($properties) as $merge) {
728 $combinations[] = array_merge(array($key => $value), $merge);
729 }
730 }
731 else {
732 $combinations[] = array($key => $value);
733 }
734 }
735 return $combinations;
736 }
737
738 /**
739 * Get multiple translations with search conditions.
740 *
741 * @param $translations
742 * Array of translation objects as loaded from the db.
743 * @param $langcode
744 * Language code, array of language codes or * to search all translations.
745 *
746 * @return array
747 * Array of i18n string objects.
748 */
749 protected function multiple_translation_build($translations, $langcode) {
750 $strings = array();
751 foreach ($translations as $translation) {
752 // The string object may be already in list
753 if (isset($strings[$translation->context])) {
754 $string = $strings[$translation->context];
755 }
756 else {
757 $string = $this->build_string($translation->context);
758 $string->set_properties($translation);
759 $strings[$string->context] = $string;
760 }
761 // If this is a translation we set it there too
762 if ($translation->language && $translation->translation) {
763 $string->set_translation($translation);
764 }
765 elseif ($langcode) {
766 // This may only happen when we have a source string but not translation.
767 $string->set_translation(FALSE, $langcode);
768 }
769 }
770 return $strings;
771 }
772
773 /**
774 * Load multiple translations from db
775 *
776 * @todo Optimize when we've already got the source object
777 *
778 * @param $conditions
779 * Array of field values to use as query conditions.
780 * @param $langcode
781 * Language code to search.
782 * @param $index
783 * Field to use as index for the result.
784 * @return array
785 * Array of string objects with translation set.
786 */
787 protected function multiple_translation_load($conditions, $langcode) {
788 $conditions += array(
789 'language' => $langcode,
790 'textgroup' => $this->textgroup
791 );
792 // We may be querying all translations at the same time or just one language.
793 // The language field needs some special treatment though.
794 $query = db_select('i18n_string', 's')->fields('s');
795 $query->leftJoin('locales_target', 't', 's.lid = t.lid');
796 $query->fields('t', array('translation', 'language', 'i18n_status'));
797 foreach ($conditions as $field => $value) {
798 // Single array value, reduce array
799 if (is_array($value) && count($value) == 1) {
800 $value = reset($value);
801 }
802 if ($value === '*') {
803 continue;
804 }
805 elseif ($field == 'language') {
806 $query->condition('t.language', $value);
807 }
808 else {
809 $query->condition('s.' . $field, $value);
810 }
811 }
812 return $this->multiple_translation_build($query->execute()->fetchAll(), $langcode);
813 }
814
815 /**
816 * Search multiple translations with key combinations.
817 *
818 * @param $context array()
819 * String context conditions.
820 * Each context field may be a single value, an array of values or '*'
821 *
822 * @return
823 * Array of translation objects indexed by context.
824 */
825 public function multiple_translation_search($context, $langcode) {
826 // First, build conditions and identify the variable field.
827 $keys = array('type', 'objectid', 'property');
828 $conditions = array_combine($keys, $context) + array('language' => $langcode);
829 // Find existing searches in cache, compile remaining ones.
830 $translations = $search = array();
831 foreach ($this->multiple_combine($conditions) as $combination) {
832 $cached = $this->multiple_cache_get($combination);
833 if (isset($cached)) {
834 // Cache hit. Merge and remove value from search.
835 $translations += $cached;
836 }
837 else {
838 // Not in cache, add to search conditions.
839 $search = array_merge_recursive($search, $combination);
840 }
841 }
842 // If we've got any search values left, find translations.
843 if ($search) {
844 // Prepare conditions for search, de-dupe conditions values.
845 foreach ($search as $key => $value) {
846 if (is_array($value)) {
847 $search[$key] = array_unique($value);
848 }
849 }
850 // Load translations for conditions and set them to the cache
851 $loaded = $this->multiple_translation_load($search, $langcode);
852 if ($loaded) {
853 $translations += $loaded;
854 }
855 // Set cache for each of the multiple search keys.
856 foreach ($this->multiple_combine($search) as $combination) {
857 $list = $loaded ? $this->string_filter($loaded, $combination) : array();
858 $this->multiple_cache_set($combination, $list);
859 }
860 }
861 return $translations;
862 }
863
864 /**
865 * Set multiple cache.
866 *
867 * @param $context
868 * String context with language property at the end.
869 * @param $strings
870 * Array of strings (may be empty) to cache.
871 */
872 protected function multiple_cache_set($context, $strings) {
873 $cache_key = implode(':', $context);
874 $this->cache_multiple[$cache_key] = $strings;
875 }
876
877 /**
878 * Get strings from multiple cache.
879 *
880 * @param $context
881 * String context with language property at the end.
882 *
883 * @return mixed
884 * Array of strings (may be empty) if we've got a cache hit.
885 * Null otherwise.
886 */
887 protected function multiple_cache_get($context) {
888 $cache_key = implode(':', $context);
889 if (isset($this->cache_multiple[$cache_key])) {
890 return $this->cache_multiple[$cache_key];
891 }
892 else {
893 // Now we try more generic keys. For instance, if we are searching 'term:1:*'
894 // we may try too 'term:*:*' and filter out the results.
895 foreach ($context as $key => $value) {
896 if ($value != '*') {
897 $try = array_merge($context, array($key => '*'));
898 $cache_key = implode(':', $try);
899 if (isset($this->cache_multiple[$cache_key])) {
900 // As we've found some more generic key, we need to filter using original conditions.
901 $strings = $this->string_filter($this->cache_multiple[$cache_key], $context);
902 return $strings;
903 }
904 }
905 }
906 // If we've reached here, we didn't find any cache match.
907 return NULL;
908 }
909 }
910
911 /**
912 * Translate array of source strings
913 *
914 * @param $context
915 * Context array with placeholders (*)
916 * @param $strings
917 * Optional array of source strings indexed by the placeholder property
918 *
919 * @return array
920 * Array of string objects (with translation) indexed by the placeholder field
921 */
922 public function multiple_translate($context, $strings = array(), $options = array()) {
923 // First, build conditions and identify the variable field
924 $search = $context = array_combine(array('type', 'objectid', 'property'), $context);
925 $langcode = isset($options['langcode']) ? $options['langcode'] : i18n_langcode();
926 // If we've got keyed source strings set the array of keys on the placeholder field
927 // or if not, remove that condition so we search all strings with that keys.
928 foreach ($search as $field => $value) {
929 if ($value === '*') {
930 $property = $field;
931 if ($strings) {
932 $search[$field] = array_keys($strings);
933 }
934 }
935 }
936 // Now we'll add the language code to conditions and get the translations indexed by the property field
937 $result = $this->multiple_translation_search($search, $langcode);
938 // Remap translations using property field. If we've got strings it is important that they are in the same order.
939 $translations = $strings;
940 foreach ($result as $key => $i18nstring) {
941 $translations[$i18nstring->$property] = $i18nstring;
942 }
943 // Set strings as source or create
944 foreach ($strings as $key => $source) {
945 if (isset($translations[$key]) && is_object($translations[$key])) {
946 $translations[$key]->set_string($source);
947 }
948 else {
949 // Not found any string for this property, create it to map in the response
950 // But make sure we set this language's translation to FALSE so we don't search again
951 $newcontext = $context;
952 $newcontext[$property] = $key;
953 $translations[$key] = $this->build_string($newcontext)
954 ->set_string($source)
955 ->set_translation(FALSE, $langcode);
956 }
957 }
958 return $translations;
959 }
960
961 /**
962 * Update string translation, only if source exists.
963 *
964 * @param $context
965 * String context as array
966 * @param $langcode
967 * Language code to create the translation for
968 * @param $translation
969 * String translation for this language
970 */
971 function update_translation($context, $langcode, $translation) {
972 $i18nstring = $this->build_string($context);
973 if ($source = $i18nstring->get_source()) {
974 $source->set_translation($translation, $langcode);
975 $this->save_translation($source, $langcode);
976 return $source;
977 }
978 }
979
980 /**
981 * Recheck strings after update
982 */
983 public function update_check() {
984 // Find strings in locales_source that have no data in i18n_string
985 $query = db_select('locales_source', 'l')
986 ->fields('l')
987 ->condition('l.textgroup', $this->textgroup);
988 $alias = $query->leftJoin('i18n_string', 's', 'l.lid = s.lid');
989 $query->condition('s.lid', NULL);
990 foreach ($query->execute()->fetchAll() as $string) {
991 $i18nstring = $this->build_string($string->context, $string->source);
992 $this->save_string($i18nstring);
993 }
994 }
995
996 }
997
998 /**
999 * String object wrapper
1000 */
1001 class i18n_string_object_wrapper extends i18n_object_wrapper {
1002 // Text group object
1003 protected $textgroup;
1004 // Properties for translation
1005 protected $properties;
1006
1007 /**
1008 * Get object strings for translation
1009 *
1010 * This will return a simple array of string objects, indexed by full string name.
1011 *
1012 * @param $options
1013 * Array with processing options.
1014 * - 'empty', whether to return empty strings, defaults to FALSE.
1015 */
1016 public function get_strings($options = array()) {
1017 $options += array('empty' => FALSE);
1018 $strings = array();
1019 foreach ($this->get_properties() as $textgroup => $textgroup_list) {
1020 foreach ($textgroup_list as $type => $type_list) {
1021 foreach ($type_list as $object_id => $object_list) {
1022 foreach ($object_list as $key => $string) {
1023 if ($options['empty'] || !empty($string['string'])) {
1024 // Build string object, that will trigger static caches everywhere.
1025 $i18nstring = i18n_string_textgroup($textgroup)
1026 ->build_string(array($type, $object_id, $key))
1027 ->set_string($string);
1028 $strings[$i18nstring->get_name()] = $i18nstring;
1029 }
1030 }
1031 }
1032 }
1033 }
1034 return $strings;
1035 }
1036 /**
1037 * Get object translatable properties
1038 *
1039 * This will return a big array indexed by textgroup, object type, object id and string key.
1040 * Each element is an array with string information, and may have these properties:
1041 * - 'string', the string itself, will be NULL if the object doesn't have that string
1042 * - 'format', string format when needed
1043 * - 'title', string readable name
1044 */
1045 public function get_properties() {
1046 if (!isset($this->properties)) {
1047 $this->properties = $this->build_properties();
1048 }
1049 return $this->properties;
1050 }
1051
1052 /**
1053 * Build properties from object.
1054 */
1055 protected function build_properties() {
1056 list($string_type, $object_id) = $this->get_string_context();
1057 $object_keys = array(
1058 $this->get_textgroup(),
1059 $string_type,
1060 $object_id,
1061 );
1062 $strings = array();
1063 foreach ($this->get_string_info('properties', array()) as $field => $info) {
1064 $info = is_array($info) ? $info : array('title' => $info);
1065 $field_name = isset($info['field']) ? $info['field'] : $field;
1066 $value = $this->get_field($field_name);
1067 $strings[$this->get_textgroup()][$string_type][$object_id][$field] = array(
1068 'string' => is_array($value) || isset($info['empty']) && $value === $info['empty'] ? NULL : $value,
1069 'title' => $info['title'],
1070 'format' => isset($info['format']) ? $this->get_field($info['format']) : NULL,
1071 'name' => array_merge($object_keys, array($field)),
1072 );
1073 }
1074 // Call hook_i18n_string_list_TEXTGROUP_alter(), last chance for modules
1075 drupal_alter('i18n_string_list_' . $this->get_textgroup(), $strings, $this->type, $this->object);
1076 return $strings;
1077 }
1078
1079 /**
1080 * Get string context
1081 */
1082 public function get_string_context() {
1083 return array($this->get_string_info('type'), $this->get_key());
1084 }
1085
1086 /**
1087 * Get translate path for object
1088 *
1089 * @param $langcode
1090 * Language code if we want ti for a specific language
1091 */
1092 public function get_translate_path($langcode = NULL) {
1093 $replacements = array('%language' => $langcode ? $langcode : '');
1094 if ($path = $this->get_string_info('translate path')) {
1095 return $this->path_replace($path, $replacements);
1096 }
1097 elseif ($path = $this->get_info('translate tab')) {
1098 // If we've got a translate tab path, we just add language to it
1099 return $this->path_replace($path . '/%language', $replacements);
1100 }
1101 }
1102
1103 /**
1104 * Translation mode for object
1105 */
1106 public function get_translate_mode() {
1107 return !$this->get_langcode() ? I18N_MODE_LOCALIZE : I18N_MODE_NONE;
1108 }
1109
1110 /**
1111 * Get textgroup name
1112 */
1113 public function get_textgroup() {
1114 return $this->get_string_info('textgroup');
1115 }
1116
1117 /**
1118 * Get textgroup object
1119 */
1120 protected function textgroup() {
1121 if (!isset($this->textgroup)) {
1122 $this->textgroup = i18n_string_textgroup($this->get_textgroup());
1123 }
1124 return $this->textgroup;
1125 }
1126
1127 /**
1128 * Translate object.
1129 *
1130 * Translations are cached so it runs only once per language.
1131 *
1132 * @return object/array
1133 * A clone of the object with its properties translated.
1134 */
1135 public function translate($langcode, $options = array()) {
1136 // We may have it already translated. As objects are statically cached, translations are too.
1137 if (!isset($this->translations[$langcode])) {
1138 $this->translations[$langcode] = $this->translate_object($langcode, $options);
1139 }
1140 return $this->translations[$langcode];
1141 }
1142
1143 /**
1144 * Translate access (localize strings)
1145 */
1146 protected function localize_access() {
1147 return user_access('translate interface');
1148 }
1149
1150 /**
1151 * Translate all properties for object.
1152 *
1153 * On top of object strings we search for all textgroup:type:objectid:* properties
1154 *
1155 * @param $langcode
1156 * A clone of the object or array
1157 */
1158 protected function translate_object($langcode, $options) {
1159 // Clone object or array so we don't affect the original one.
1160 $object = is_object($this->object) ? clone $this->object : $this->object;
1161 // Get object strings for translatable properties.
1162 if ($strings = $this->get_strings()) {
1163 // We preload some of the property translations with a single query.
1164 if ($context = $this->get_translate_context($langcode, $options)) {
1165 $found = $this->textgroup()->multiple_translation_search($context, $langcode);
1166 }
1167 // Replace all strings in object.
1168 foreach ($strings as $i18nstring) {
1169 $this->translate_field($object, $i18nstring, $langcode, $options);
1170 }
1171 }
1172 return $object;
1173 }
1174
1175 /**
1176 * Context to be pre-loaded before translation.
1177 */
1178 protected function get_translate_context($langcode, $options) {
1179 // One-query translation of all textgroup:type:objectid:* properties
1180 $context = $this->get_string_context();
1181 $context[] = '*';
1182 return $context;
1183 }
1184
1185 /**
1186 * Translate object property.
1187 *
1188 * Mot often, this is a direct field set, but sometimes fields may have different formats.
1189 */
1190 protected function translate_field(&$object, $i18nstring, $langcode, $options) {
1191 $field_name = $i18nstring->property;
1192 $translation = $i18nstring->format_translation($langcode, $options);
1193 if (is_object($object)) {
1194 $object->$field_name = $translation;
1195 }
1196 elseif (is_array($object)) {
1197 $object[$field_name] = $translation;
1198 }
1199 }
1200
1201 /**
1202 * Remove all strings for this object.
1203 */
1204 public function strings_remove($options = array()) {
1205 return $this->strings_operation('remove', $options + array('empty' => TRUE));
1206 }
1207
1208 /**
1209 * Update all strings for this object.
1210 */
1211 public function strings_update($options = array()) {
1212 return $this->strings_operation('update', $options + array('empty' => TRUE, 'update' => TRUE));
1213 }
1214
1215 /**
1216 * Run some operation on all this object's strings.
1217 *
1218 * @param $method
1219 * Operation to run on all strings: 'update', 'remove'.
1220 * @param $options
1221 * Array of options to pass to the string method.
1222 *
1223 * @return array
1224 * Count for each result
1225 */
1226 protected function strings_operation($method, $options = array()) {
1227 $result = array();
1228 foreach ($this->get_strings($options) as $string) {
1229 $status = $string->$method($options);
1230 $result[$status] = isset($result[$status]) ? $result[$status] +1 : 1;
1231 }
1232 return $result;
1233 }
1234 }