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