PluginProbe
Polylang / 2.9.2
Polylang v2.9.2
3.8.9 3.8.8 3.8.7 3.8.6 3.8.5 3.8.4 3.8.3 2.7 2.7.0.1 2.7.1 2.7.2 2.7.3 2.7.4 2.8 2.8.1 2.8.2 2.8.3 2.8.4 2.9 2.9.1 2.9.2 3.0 3.0.1 3.0.2 3.0.3 All 233 releases
polylang / admin / admin-model.php

admin-model.php in Polylang 2.9.2, at admin/admin-model.php

604 lines 20.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * @package Polylang
4 */
5
6 /**
7 * Extends the PLL_Model class with methods needed only in Polylang settings pages
8 *
9 * @since 1.2
10 */
11 class PLL_Admin_Model extends PLL_Model {
12
13 /**
14 * Adds a new language
15 * Creates a default category for this language
16 *
17 * List of arguments that $args must contain:
18 * name -> language name ( used only for display )
19 * slug -> language code ( ideally 2-letters ISO 639-1 language code )
20 * locale -> WordPress locale. If something wrong is used for the locale, the .mo files will not be loaded...
21 * rtl -> 1 if rtl language, 0 otherwise
22 * term_group -> language order when displayed
23 *
24 * Optional arguments that $args can contain:
25 * no_default_cat -> if set, no default category will be created for this language
26 * flag -> country code, see flags.php
27 *
28 * @since 1.2
29 *
30 * @param array $args
31 * @return bool true if success / false if failed
32 */
33 public function add_language( $args ) {
34 $errors = $this->validate_lang( $args );
35 if ( $errors->get_error_code() ) { // Using has_errors() would be more meaningful but is available only since WP 5.0
36 return $errors;
37 }
38
39 // First the language taxonomy
40 $description = maybe_serialize( array( 'locale' => $args['locale'], 'rtl' => (int) $args['rtl'], 'flag_code' => empty( $args['flag'] ) ? '' : $args['flag'] ) );
41 $r = wp_insert_term( $args['name'], 'language', array( 'slug' => $args['slug'], 'description' => $description ) );
42 if ( is_wp_error( $r ) ) {
43 // Avoid an ugly fatal error if something went wrong ( reported once in the forum )
44 return new WP_Error( 'pll_add_language', __( 'Impossible to add the language.', 'polylang' ) );
45 }
46 wp_update_term( (int) $r['term_id'], 'language', array( 'term_group' => (int) $args['term_group'] ) ); // can't set the term group directly in wp_insert_term
47
48 // The term_language taxonomy
49 // Don't want shared terms so use a different slug
50 wp_insert_term( $args['name'], 'term_language', array( 'slug' => 'pll_' . $args['slug'] ) );
51
52 $this->clean_languages_cache(); // Update the languages list now !
53
54 if ( ! isset( $this->options['default_lang'] ) ) {
55 // If this is the first language created, set it as default language
56 $this->options['default_lang'] = $args['slug'];
57 update_option( 'polylang', $this->options );
58
59 // And assign default language to default category
60 $this->term->set_language( (int) get_option( 'default_category' ), (int) $r['term_id'] );
61 } elseif ( empty( $args['no_default_cat'] ) ) {
62 $this->create_default_category( $args['slug'] );
63 }
64
65 // Init a mo_id for this language
66 $mo = new PLL_MO();
67 $mo->export_to_db( $this->get_language( $args['slug'] ) );
68
69 /**
70 * Fires when a language is added
71 *
72 * @since 1.9
73 *
74 * @param array $args arguments used to create the language
75 */
76 do_action( 'pll_add_language', $args );
77
78 $this->clean_languages_cache(); // Again to set add mo_id in the cached languages list
79 flush_rewrite_rules(); // Refresh rewrite rules
80 return true;
81 }
82
83 /**
84 * Delete a language
85 *
86 * @since 1.2
87 *
88 * @param int $lang_id language term_id
89 */
90 public function delete_language( $lang_id ) {
91 $lang = $this->get_language( (int) $lang_id );
92
93 if ( empty( $lang ) ) {
94 return false;
95 }
96
97 // Oops ! we are deleting the default language...
98 // Need to do this before loosing the information for default category translations
99 if ( $this->options['default_lang'] == $lang->slug ) {
100 $slugs = $this->get_languages_list( array( 'fields' => 'slug' ) );
101 $slugs = array_diff( $slugs, array( $lang->slug ) );
102
103 if ( ! empty( $slugs ) ) {
104 $this->update_default_lang( reset( $slugs ) ); // Arbitrary choice...
105 } else {
106 unset( $this->options['default_lang'] );
107 }
108 }
109
110 // Delete the translations
111 $this->update_translations( $lang->slug );
112
113 // Delete language option in widgets
114 foreach ( $GLOBALS['wp_registered_widgets'] as $widget ) {
115 if ( ! empty( $widget['callback'][0] ) && ! empty( $widget['params'][0]['number'] ) ) {
116 $obj = $widget['callback'][0];
117 $number = $widget['params'][0]['number'];
118 if ( is_object( $obj ) && method_exists( $obj, 'get_settings' ) && method_exists( $obj, 'save_settings' ) ) {
119 $settings = $obj->get_settings();
120 if ( isset( $settings[ $number ]['pll_lang'] ) && $settings[ $number ]['pll_lang'] == $lang->slug ) {
121 unset( $settings[ $number ]['pll_lang'] );
122 $obj->save_settings( $settings );
123 }
124 }
125 }
126 }
127
128 // Delete menus locations
129 if ( ! empty( $this->options['nav_menus'] ) ) {
130 foreach ( $this->options['nav_menus'] as $theme => $locations ) {
131 foreach ( array_keys( $locations ) as $location ) {
132 unset( $this->options['nav_menus'][ $theme ][ $location ][ $lang->slug ] );
133 }
134 }
135 }
136
137 // Delete users options
138 foreach ( get_users( array( 'fields' => 'ID' ) ) as $user_id ) {
139 delete_user_meta( $user_id, 'pll_filter_content', $lang->slug );
140 delete_user_meta( $user_id, 'description_' . $lang->slug );
141 }
142
143 // Delete the string translations
144 $post = wpcom_vip_get_page_by_title( 'polylang_mo_' . $lang->term_id, OBJECT, 'polylang_mo' );
145 if ( ! empty( $post ) ) {
146 wp_delete_post( $post->ID );
147 }
148
149 // Delete domain
150 unset( $this->options['domains'][ $lang->slug ] );
151
152 // Delete the language itself
153 wp_delete_term( $lang->term_id, 'language' );
154 wp_delete_term( $lang->tl_term_id, 'term_language' );
155
156 // Update languages list
157 $this->clean_languages_cache();
158
159 update_option( 'polylang', $this->options );
160 flush_rewrite_rules(); // refresh rewrite rules
161 return true;
162 }
163
164 /**
165 * Update language properties
166 *
167 * List of arguments that $args must contain:
168 * lang_id -> term_id of the language to modify
169 * name -> language name ( used only for display )
170 * slug -> language code ( ideally 2-letters ISO 639-1 language code
171 * locale -> WordPress locale. If something wrong is used for the locale, the .mo files will not be loaded...
172 * rtl -> 1 if rtl language, 0 otherwise
173 * term_group -> language order when displayed
174 *
175 * Optional arguments that $args can contain:
176 * flag -> country code, see flags.php
177 *
178 * @since 1.2
179 *
180 * @param array $args
181 * @return bool true if success / false if failed
182 */
183 public function update_language( $args ) {
184 $lang = $this->get_language( (int) $args['lang_id'] );
185
186 $errors = $this->validate_lang( $args, $lang );
187 if ( $errors->get_error_code() ) { // Using has_errors() would be more meaningful but is available only since WP 5.0
188 return $errors;
189 }
190
191 // Update links to this language in posts and terms in case the slug has been modified
192 $slug = $args['slug'];
193 $old_slug = $lang->slug;
194
195 if ( $old_slug != $slug ) {
196 // Update the language slug in translations
197 $this->update_translations( $old_slug, $slug );
198
199 // Update language option in widgets
200 foreach ( $GLOBALS['wp_registered_widgets'] as $widget ) {
201 if ( ! empty( $widget['callback'][0] ) && ! empty( $widget['params'][0]['number'] ) ) {
202 $obj = $widget['callback'][0];
203 $number = $widget['params'][0]['number'];
204 if ( is_object( $obj ) && method_exists( $obj, 'get_settings' ) && method_exists( $obj, 'save_settings' ) ) {
205 $settings = $obj->get_settings();
206 if ( isset( $settings[ $number ]['pll_lang'] ) && $settings[ $number ]['pll_lang'] == $old_slug ) {
207 $settings[ $number ]['pll_lang'] = $slug;
208 $obj->save_settings( $settings );
209 }
210 }
211 }
212 }
213
214 // Update menus locations
215 if ( ! empty( $this->options['nav_menus'] ) ) {
216 foreach ( $this->options['nav_menus'] as $theme => $locations ) {
217 foreach ( array_keys( $locations ) as $location ) {
218 if ( ! empty( $this->options['nav_menus'][ $theme ][ $location ][ $old_slug ] ) ) {
219 $this->options['nav_menus'][ $theme ][ $location ][ $slug ] = $this->options['nav_menus'][ $theme ][ $location ][ $old_slug ];
220 unset( $this->options['nav_menus'][ $theme ][ $location ][ $old_slug ] );
221 }
222 }
223 }
224 }
225
226 // Update domains
227 if ( ! empty( $this->options['domains'][ $old_slug ] ) ) {
228 $this->options['domains'][ $slug ] = $this->options['domains'][ $old_slug ];
229 unset( $this->options['domains'][ $old_slug ] );
230 }
231
232 // Update the default language option if necessary
233 if ( $this->options['default_lang'] == $old_slug ) {
234 $this->options['default_lang'] = $slug;
235 }
236 }
237
238 update_option( 'polylang', $this->options );
239
240 // And finally update the language itself
241 $description = maybe_serialize( array( 'locale' => $args['locale'], 'rtl' => (int) $args['rtl'], 'flag_code' => empty( $args['flag'] ) ? '' : $args['flag'] ) );
242 wp_update_term( (int) $lang->term_id, 'language', array( 'slug' => $slug, 'name' => $args['name'], 'description' => $description, 'term_group' => (int) $args['term_group'] ) );
243 wp_update_term( (int) $lang->tl_term_id, 'term_language', array( 'slug' => 'pll_' . $slug, 'name' => $args['name'] ) );
244
245 /**
246 * Fires when a language is added
247 *
248 * @since 1.9
249 *
250 * @param array $args arguments used to modify the language
251 */
252 do_action( 'pll_update_language', $args );
253
254 $this->clean_languages_cache();
255 flush_rewrite_rules(); // Refresh rewrite rules
256 return true;
257 }
258
259 /**
260 * Validates data entered when creating or updating a language
261 *
262 * @see PLL_Admin_Model::add_language()
263 *
264 * @since 0.4
265 *
266 * @param array $args
267 * @param object $lang optional the language currently updated, the language is created if not set
268 * @return bool true if success / false if failed
269 */
270 protected function validate_lang( $args, $lang = null ) {
271 $errors = new WP_Error();
272
273 // Validate locale with the same pattern as WP 4.3. See #28303
274 if ( ! preg_match( '#^[a-z]{2,3}(?:_[A-Z]{2})?(?:_[a-z0-9]+)?$#', $args['locale'], $matches ) ) {
275 $errors->add( 'pll_invalid_locale', __( 'Enter a valid WordPress locale', 'polylang' ) );
276 }
277
278 // Validate slug characters
279 if ( ! preg_match( '#^[a-z_-]+$#', $args['slug'] ) ) {
280 $errors->add( 'pll_invalid_slug', __( 'The language code contains invalid characters', 'polylang' ) );
281 }
282
283 // Validate slug is unique
284 foreach ( $this->get_languages_list() as $language ) {
285 if ( $language->slug === $args['slug'] && ( null === $lang || ( isset( $lang ) && $lang->term_id != $language->term_id ) ) ) {
286 $errors->add( 'pll_non_unique_slug', __( 'The language code must be unique', 'polylang' ) );
287 }
288 }
289
290 // Validate name
291 // No need to sanitize it as wp_insert_term will do it for us
292 if ( empty( $args['name'] ) ) {
293 $errors->add( 'pll_invalid_name', __( 'The language must have a name', 'polylang' ) );
294 }
295
296 // Validate flag
297 if ( ! empty( $args['flag'] ) && ! file_exists( POLYLANG_DIR . '/flags/' . $args['flag'] . '.png' ) ) {
298 $flag = PLL_Language::get_flag_informations( $args['flag'] );
299
300 if ( ! empty( $flag['url'] ) ) {
301 $response = function_exists( 'vip_safe_wp_remote_get' ) ? vip_safe_wp_remote_get( esc_url_raw( $flag['url'] ) ) : wp_remote_get( esc_url_raw( $flag['url'] ) );
302 }
303
304 if ( empty( $response ) || is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
305 $errors->add( 'pll_invalid_flag', __( 'The flag does not exist', 'polylang' ) );
306 }
307 }
308
309 return $errors;
310 }
311
312 /**
313 * Used to set the language of posts or terms in mass
314 *
315 * @since 1.2
316 *
317 * @param string $type either 'post' or 'term'
318 * @param array $ids array of post ids or term ids
319 * @param object|string $lang object or slug
320 */
321 public function set_language_in_mass( $type, $ids, $lang ) {
322 global $wpdb;
323
324 $ids = array_map( 'intval', $ids );
325 $lang = $this->get_language( $lang );
326 $tt_id = 'term' === $type ? $lang->tl_term_taxonomy_id : $lang->term_taxonomy_id;
327 $values = array();
328
329 foreach ( $ids as $id ) {
330 $values[] = $wpdb->prepare( '( %d, %d )', $id, $tt_id );
331 }
332
333 if ( ! empty( $values ) ) {
334 // PHPCS:ignore WordPress.DB.PreparedSQL.NotPrepared
335 $wpdb->query( "INSERT INTO {$wpdb->term_relationships} ( object_id, term_taxonomy_id ) VALUES " . implode( ',', array_unique( $values ) ) );
336 $lang->update_count(); // Updating term count is mandatory ( thanks to AndyDeGroo )
337 }
338
339 if ( 'term' === $type ) {
340 clean_term_cache( $ids, 'term_language' );
341 $translations = array();
342
343 foreach ( $ids as $id ) {
344 $translations[] = array( $lang->slug => $id );
345 }
346
347 if ( ! empty( $translations ) ) {
348 $this->set_translation_in_mass( 'term', $translations );
349 }
350 } else {
351 clean_term_cache( $ids, 'language' );
352 }
353 }
354
355 /**
356 * Used to create a translations groups in mass
357 *
358 * @since 1.6.3
359 *
360 * @param string $type either 'post' or 'term'
361 * @param array $translations array of translations arrays
362 */
363 public function set_translation_in_mass( $type, $translations ) {
364 global $wpdb;
365
366 $taxonomy = $type . '_translations';
367 $terms = array();
368 $slugs = array();
369 $description = array();
370 $count = array();
371
372 foreach ( $translations as $t ) {
373 $term = uniqid( 'pll_' ); // the term name
374 $terms[] = $wpdb->prepare( '( %s, %s )', $term, $term );
375 $slugs[] = $wpdb->prepare( '%s', $term );
376 $description[ $term ] = maybe_serialize( $t );
377 $count[ $term ] = count( $t );
378 }
379
380 // Insert terms
381 if ( ! empty( $terms ) ) {
382 // PHPCS:ignore WordPress.DB.PreparedSQL.NotPrepared
383 $wpdb->query( "INSERT INTO {$wpdb->terms} ( slug, name ) VALUES " . implode( ',', array_unique( $terms ) ) );
384 }
385
386 // Get all terms with their term_id
387 // PHPCS:ignore WordPress.DB.PreparedSQL.NotPrepared
388 $terms = $wpdb->get_results( "SELECT term_id, slug FROM {$wpdb->terms} WHERE slug IN ( " . implode( ',', $slugs ) . ' )' );
389 $term_ids = array();
390 $tts = array();
391
392 // Prepare terms taxonomy relationship
393 foreach ( $terms as $term ) {
394 $term_ids[] = $term->term_id;
395 $tts[] = $wpdb->prepare( '( %d, %s, %s, %d )', $term->term_id, $taxonomy, $description[ $term->slug ], $count[ $term->slug ] );
396 }
397
398 // Insert term_taxonomy
399 if ( ! empty( $tts ) ) {
400 // PHPCS:ignore WordPress.DB.PreparedSQL.NotPrepared
401 $wpdb->query( "INSERT INTO {$wpdb->term_taxonomy} ( term_id, taxonomy, description, count ) VALUES " . implode( ',', array_unique( $tts ) ) );
402 }
403
404 // Get all terms with term_taxonomy_id
405 $terms = get_terms( $taxonomy, array( 'hide_empty' => false ) );
406 $trs = array();
407
408 // Prepare objects relationships
409 foreach ( $terms as $term ) {
410 $t = maybe_unserialize( $term->description );
411 if ( in_array( $t, $translations ) ) {
412 foreach ( $t as $object_id ) {
413 if ( ! empty( $object_id ) ) {
414 $trs[] = $wpdb->prepare( '( %d, %d )', $object_id, $term->term_taxonomy_id );
415 }
416 }
417 }
418 }
419
420 // Insert term_relationships
421 if ( ! empty( $trs ) ) {
422 // PHPCS:ignore WordPress.DB.PreparedSQL.NotPrepared
423 $wpdb->query( "INSERT INTO {$wpdb->term_relationships} ( object_id, term_taxonomy_id ) VALUES " . implode( ',', $trs ) );
424 $trs = array_unique( $trs );
425 }
426
427 clean_term_cache( $term_ids, $taxonomy );
428 }
429
430 /**
431 * Returns untranslated posts and terms ids ( used in settings )
432 *
433 * @since 0.9
434 * @since 2.2.6 Add the $limit argument
435 *
436 * @param in $limit Max number of posts or terms to return. Defaults to -1 (no limit).
437 * @return array Array made of an array of post ids and an array of term ids
438 */
439 public function get_objects_with_no_lang( $limit = -1 ) {
440 global $wpdb;
441
442 /**
443 * Filters the max number of posts or terms to return when searching objects with no language
444 * This filter can be used to decrease the memory usage in case the number of objects
445 * without language is too big. Using a negative value is equivalent to have no limit.
446 *
447 * @since 2.2.6
448 *
449 * @param int $limit Max number of posts or terms to retrieve from the database
450 */
451 $limit = (int) apply_filters( 'get_objects_with_no_lang_limit', $limit );
452
453 $posts = get_posts(
454 array(
455 'numberposts' => $limit,
456 'nopaging' => $limit <= 0,
457 'post_type' => $this->get_translated_post_types(),
458 'post_status' => 'any',
459 'fields' => 'ids',
460 'tax_query' => array(
461 array(
462 'taxonomy' => 'language',
463 'terms' => $this->get_languages_list( array( 'fields' => 'term_id' ) ),
464 'operator' => 'NOT IN',
465 ),
466 ),
467 )
468 );
469
470 // PHPCS:disable WordPress.DB.PreparedSQL
471 $terms = $wpdb->get_col(
472 sprintf(
473 "SELECT {$wpdb->term_taxonomy}.term_id FROM {$wpdb->term_taxonomy}
474 WHERE taxonomy IN ('%s')
475 AND {$wpdb->term_taxonomy}.term_id NOT IN (
476 SELECT object_id FROM {$wpdb->term_relationships} WHERE term_taxonomy_id IN (%s)
477 )
478 %s",
479 implode( "','", array_map( 'esc_sql', $this->get_translated_taxonomies() ) ),
480 implode( ',', array_map( 'intval', $this->get_languages_list( array( 'fields' => 'tl_term_taxonomy_id' ) ) ) ),
481 $limit > 0 ? "LIMIT {$limit}" : ''
482 )
483 );
484 // PHPCS:enable
485
486 /**
487 * Filter the list of untranslated posts ids and terms ids
488 *
489 * @since 0.9
490 *
491 * @param bool|array $objects false if no ids found, list of post and/or term ids otherwise
492 */
493 return apply_filters( 'pll_get_objects_with_no_lang', empty( $posts ) && empty( $terms ) ? false : array( 'posts' => $posts, 'terms' => $terms ) );
494 }
495
496 /**
497 * Used to delete translations or update the translations when a language slug has been modified in settings
498 *
499 * @since 0.5
500 *
501 * @param string $old_slug the old language slug
502 * @param string $new_slug optional, the new language slug, if not set it means the correspondent has been deleted
503 */
504 public function update_translations( $old_slug, $new_slug = '' ) {
505 global $wpdb;
506
507 $terms = get_terms( array( 'post_translations', 'term_translations' ) );
508 $term_ids = array();
509 $dr = array();
510 $dt = array();
511 $ut = array();
512
513 foreach ( $terms as $term ) {
514 $term_ids[ $term->taxonomy ][] = $term->term_id;
515 $tr = maybe_unserialize( $term->description );
516 if ( ! empty( $tr[ $old_slug ] ) ) {
517 if ( $new_slug ) {
518 $tr[ $new_slug ] = $tr[ $old_slug ]; // Suppress this for delete
519 } else {
520 $dr['id'][] = (int) $tr[ $old_slug ];
521 $dr['tt'][] = (int) $term->term_taxonomy_id;
522 }
523 unset( $tr[ $old_slug ] );
524
525 if ( empty( $tr ) || 1 == count( $tr ) ) {
526 $dt['t'][] = (int) $term->term_id;
527 $dt['tt'][] = (int) $term->term_taxonomy_id;
528 } else {
529 $ut['case'][] = $wpdb->prepare( 'WHEN %d THEN %s', $term->term_id, maybe_serialize( $tr ) );
530 $ut['in'][] = (int) $term->term_id;
531 }
532 }
533 }
534
535 // Delete relationships
536 if ( ! empty( $dr ) ) {
537 // PHPCS:disable WordPress.DB.PreparedSQL.NotPrepared
538 $wpdb->query(
539 "DELETE FROM $wpdb->term_relationships
540 WHERE object_id IN ( " . implode( ',', $dr['id'] ) . ' )
541 AND term_taxonomy_id IN ( ' . implode( ',', $dr['tt'] ) . ' )'
542 );
543 // PHPCS:enable
544 }
545
546 // Delete terms
547 if ( ! empty( $dt ) ) {
548 $wpdb->query( "DELETE FROM $wpdb->terms WHERE term_id IN ( " . implode( ',', $dt['t'] ) . ' )' ); // PHPCS:ignore WordPress.DB.PreparedSQL.NotPrepared
549 $wpdb->query( "DELETE FROM $wpdb->term_taxonomy WHERE term_taxonomy_id IN ( " . implode( ',', $dt['tt'] ) . ' )' ); // PHPCS:ignore WordPress.DB.PreparedSQL.NotPrepared
550 }
551
552 // Update terms
553 if ( ! empty( $ut ) ) {
554 // PHPCS:disable WordPress.DB.PreparedSQL.NotPrepared
555 $wpdb->query(
556 "UPDATE $wpdb->term_taxonomy
557 SET description = ( CASE term_id " . implode( ' ', $ut['case'] ) . ' END )
558 WHERE term_id IN ( ' . implode( ',', $ut['in'] ) . ' )'
559 );
560 // PHPCS:enable
561 }
562
563 if ( ! empty( $term_ids ) ) {
564 foreach ( $term_ids as $taxonomy => $ids ) {
565 clean_term_cache( $ids, $taxonomy );
566 }
567 }
568 }
569
570 /**
571 * Updates the default language
572 * taking care to update the default category & the nav menu locations
573 *
574 * @since 1.8
575 *
576 * @param string $slug new language slug
577 */
578 public function update_default_lang( $slug ) {
579 // The nav menus stored in theme locations should be in the default language
580 $theme = get_stylesheet();
581 if ( ! empty( $this->options['nav_menus'][ $theme ] ) ) {
582 $menus = array();
583
584 foreach ( $this->options['nav_menus'][ $theme ] as $key => $loc ) {
585 $menus[ $key ] = empty( $loc[ $slug ] ) ? 0 : $loc[ $slug ];
586 }
587 set_theme_mod( 'nav_menu_locations', $menus );
588 }
589
590 // The default category should be in the default language
591 $default_cats = $this->term->get_translations( get_option( 'default_category' ) );
592 if ( isset( $default_cats[ $slug ] ) ) {
593 update_option( 'default_category', $default_cats[ $slug ] );
594 }
595
596 // Update options
597 $this->options['default_lang'] = $slug;
598 update_option( 'polylang', $this->options );
599
600 $this->clean_languages_cache();
601 flush_rewrite_rules();
602 }
603 }
604