From fef11ae71ec1198b6eccde87aff0aec971d5debf Mon Sep 17 00:00:00 2001 From: webchick Date: Tue, 24 May 2011 08:58:16 -0700 Subject: Issue #1096340 by plach, fietserwin, sun: Fixed Stale language types/negotation info after enabling/disabling modules. --- modules/locale/locale.admin.inc | 32 ++----- modules/locale/locale.module | 20 +++- modules/locale/locale.test | 163 +++++++++++++++++++++++++++++++- modules/locale/tests/locale_test.module | 81 +++++++++++++++- 4 files changed, 268 insertions(+), 28 deletions(-) (limited to 'modules') diff --git a/modules/locale/locale.admin.inc b/modules/locale/locale.admin.inc index de16133fb..d8201dbf2 100644 --- a/modules/locale/locale.admin.inc +++ b/modules/locale/locale.admin.inc @@ -541,6 +541,12 @@ function _locale_languages_configure_form_language_table(&$form, $type) { asort($providers_weight); foreach ($providers_weight as $id => $weight) { + // A language provider might be no more available if the defining module has + // been disabled after the last configuration saving. + if (!isset($language_providers[$id])) { + continue; + } + $enabled = isset($enabled_providers[$id]); $provider = $language_providers[$id]; @@ -658,7 +664,6 @@ function theme_locale_languages_configure_form($variables) { * Submit handler for language negotiation settings. */ function locale_languages_configure_form_submit($form, &$form_state) { - $language_types = array(); $configurable_types = $form['#language_types']; foreach ($configurable_types as $type) { @@ -666,7 +671,6 @@ function locale_languages_configure_form_submit($form, &$form_state) { $enabled_providers = $form_state['values'][$type]['enabled']; $enabled_providers[LANGUAGE_NEGOTIATION_DEFAULT] = TRUE; $providers_weight = $form_state['values'][$type]['weight']; - $language_types[$type] = TRUE; foreach ($providers_weight as $id => $weight) { if ($enabled_providers[$id]) { @@ -680,27 +684,11 @@ function locale_languages_configure_form_submit($form, &$form_state) { variable_set("locale_language_providers_weight_$type", $providers_weight); } - // Save non-configurable language types negotiation. - $language_types_info = language_types_info(); - $defined_providers = $form['#language_providers']; - foreach ($language_types_info as $type => $info) { - if (isset($info['fixed'])) { - $language_types[$type] = FALSE; - $negotiation = array(); - foreach ($info['fixed'] as $weight => $id) { - if (isset($defined_providers[$id])) { - $negotiation[$id] = $defined_providers[$id]; - $negotiation[$id]['weight'] = $weight; - } - } - language_negotiation_set($type, $negotiation); - } - } - - // Save language types. - variable_set('language_types', $language_types); + // Update non-configurable language types and the related language negotiation + // configuration. + language_types_set(); - $form_state['redirect'] = 'admin/config/regional/language'; + $form_state['redirect'] = 'admin/config/regional/language/configure'; drupal_set_message(t('Language negotiation configuration saved.')); } diff --git a/modules/locale/locale.module b/modules/locale/locale.module index c1cdd434b..07884614a 100644 --- a/modules/locale/locale.module +++ b/modules/locale/locale.module @@ -513,6 +513,8 @@ function locale_language_types_info() { 'description' => t('Order of language detection methods for user interface text. If a translation of user interface text is available in the detected language, it will be displayed.'), ), LANGUAGE_TYPE_CONTENT => array( + 'name' => t('Content'), + 'description' => t('Order of language detection methods for content. If a version of content is available in the detected language, it will be displayed.'), 'fixed' => array(LOCALE_LANGUAGE_NEGOTIATION_INTERFACE), ), LANGUAGE_TYPE_URL => array( @@ -593,6 +595,22 @@ function locale_language_negotiation_info() { return $providers; } +/** + * Implements hook_modules_enabled(). + */ +function locale_modules_enabled($modules) { + include_once DRUPAL_ROOT . '/includes/language.inc'; + language_types_set(); + language_negotiation_purge(); +} + +/** + * Implements hook_modules_disabled(). + */ +function locale_modules_disabled($modules) { + locale_modules_enabled($modules); +} + // --------------------------------------------------------------------------------- // Locale core functionality @@ -928,7 +946,7 @@ function locale_block_info() { include_once DRUPAL_ROOT . '/includes/language.inc'; $block = array(); $info = language_types_info(); - foreach (language_types_configurable() as $type) { + foreach (language_types_configurable(FALSE) as $type) { $block[$type] = array( 'info' => t('Language switcher (@type)', array('@type' => $info[$type]['name'])), // Not worth caching. diff --git a/modules/locale/locale.test b/modules/locale/locale.test index 5e9e8336c..42a6dbc48 100644 --- a/modules/locale/locale.test +++ b/modules/locale/locale.test @@ -17,6 +17,7 @@ * - a functional test for multilingual support by content type and on nodes. * - a functional test for multilingual fields. * - a functional test for comment language. + * - a functional test fot language types/negotiation info. */ @@ -2248,10 +2249,13 @@ class LocaleCommentLanguageFunctionalTest extends DrupalWebTestCase { variable_set('locale_test_content_language_type', TRUE); // Set interface language detection to user and content language detection - // to URL. + // to URL. Disable inheritance from interface language to ensure content + // language will fall back to the default language if no URL language can be + // detected. $edit = array( 'language[enabled][locale-user]' => TRUE, - 'language_content[enabled][locale-url]' => TRUE + 'language_content[enabled][locale-url]' => TRUE, + 'language_content[enabled][locale-interface]' => FALSE, ); $this->drupalPost('admin/config/regional/language/configure', $edit, t('Save settings')); @@ -2373,3 +2377,158 @@ class LocaleDateFormatsFunctionalTest extends DrupalWebTestCase { $this->assertText($french_date, t('French date format appears')); } } + +/** + * Functional test for language types/negotiation info. + */ +class LocaleLanguageNegotiationInfoFunctionalTest extends DrupalWebTestCase { + + public static function getInfo() { + return array( + 'name' => 'Language negotiation info', + 'description' => 'Tests alterations to language types/negotiation info.', + 'group' => 'Locale', + ); + } + + function setUp() { + parent::setUp('locale'); + require_once DRUPAL_ROOT .'/includes/language.inc'; + $admin_user = $this->drupalCreateUser(array('administer languages', 'access administration pages', 'view the administration theme')); + $this->drupalLogin($admin_user); + $this->drupalPost('admin/config/regional/language/add', array('langcode' => 'it'), t('Add language')); + } + + /** + * Tests alterations to language types/negotiation info. + */ + function testInfoAlterations() { + // Enable language type/negotiation info alterations. + variable_set('locale_test_language_types', TRUE); + variable_set('locale_test_language_negotiation_info', TRUE); + $this->languageNegotiationUpdate(); + + // Check that fixed language types are properly configured without the need + // of saving the language negotiation settings. + $this->checkFixedLanguageTypes(); + + // Make the content language type configurable by updating the language + // negotiation settings with the proper flag enabled. + variable_set('locale_test_content_language_type', TRUE); + $this->languageNegotiationUpdate(); + $type = LANGUAGE_TYPE_CONTENT; + $language_types = variable_get('language_types', drupal_language_types()); + $this->assertTrue($language_types[$type], t('Content language type is configurable.')); + + // Enable some core and custom language providers. The test language type is + // supposed to be configurable. + $test_type = 'test_language_type'; + $provider = LOCALE_LANGUAGE_NEGOTIATION_INTERFACE; + $test_provider = 'test_language_provider'; + $form_field = $type . '[enabled]['. $provider .']'; + $edit = array( + $form_field => TRUE, + $type . '[enabled][' . $test_provider . ']' => TRUE, + $test_type . '[enabled][' . $test_provider . ']' => TRUE, + ); + $this->drupalPost('admin/config/regional/language/configure', $edit, t('Save settings')); + + // Remove the interface language provider by updating the language + // negotiation settings with the proper flag enabled. + variable_set('locale_test_language_negotiation_info_alter', TRUE); + $this->languageNegotiationUpdate(); + $negotiation = variable_get("language_negotiation_$type", array()); + $this->assertFalse(isset($negotiation[$provider]), t('Interface language provider removed from the stored settings.')); + $this->assertNoFieldByXPath("//input[@name=\"$form_field\"]", NULL, t('Interface language provider unavailable.')); + + // Check that type-specific language providers can be assigned only to the + // corresponding language types. + foreach (language_types_configurable() as $type) { + $form_field = $type . '[enabled][test_language_provider_ts]'; + if ($type == $test_type) { + $this->assertFieldByXPath("//input[@name=\"$form_field\"]", NULL, t('Type-specific test language provider available for %type.', array('%type' => $type))); + } + else { + $this->assertNoFieldByXPath("//input[@name=\"$form_field\"]", NULL, t('Type-specific test language provider unavailable for %type.', array('%type' => $type))); + } + } + + // Check language negotiation results. + $this->drupalGet(''); + $last = variable_get('locale_test_language_negotiation_last', array()); + foreach (language_types() as $type) { + $langcode = $last[$type]; + $value = $type == LANGUAGE_TYPE_CONTENT || strpos($type, 'test') !== FALSE ? 'it' : 'en'; + $this->assertEqual($langcode, $value, t('The negotiated language for %type is %language', array('%type' => $type, '%language' => $langcode))); + } + + // Disable locale_test and check that everything is set back to the original + // status. + $this->languageNegotiationUpdate('disable'); + + // Check that only the core language types are available. + foreach (language_types() as $type) { + $this->assertTrue(strpos($type, 'test') === FALSE, t('The %type language is still available', array('%type' => $type))); + } + + // Check that fixed language types are properly configured, even those + // previously set to configurable. + $this->checkFixedLanguageTypes(); + + // Check that unavailable language providers are not present in the + // negotiation settings. + $negotiation = variable_get("language_negotiation_$type", array()); + $this->assertFalse(isset($negotiation[$test_provider]), t('The disabled test language provider is not part of the content language negotiation settings.')); + + // Check that configuration page presents the correct options and settings. + $this->assertNoRaw(t('Test language detection'), t('No test language type configuration available.')); + $this->assertNoRaw(t('This is a test language provider'), t('No test language provider available.')); + } + + /** + * Update language types/negotiation information. + * + * Manually invoke locale_modules_enabled()/locale_modules_disabled() since + * they would not be invoked after enabling/disabling locale_test the first + * time. + */ + private function languageNegotiationUpdate($op = 'enable') { + static $last_op = NULL; + $modules = array('locale_test'); + + // Enable/disable locale_test only if we did not already before. + if ($last_op != $op) { + $function = "module_{$op}"; + $function($modules); + // Reset hook implementation cache. + module_implements(NULL, FALSE, TRUE); + } + + drupal_static_reset('language_types_info'); + drupal_static_reset('language_negotiation_info'); + $function = "locale_modules_{$op}d"; + if (function_exists($function)) { + $function($modules); + } + + $this->drupalGet('admin/config/regional/language/configure'); + } + + /** + * Check that language negotiation for fixed types matches the stored one. + */ + private function checkFixedLanguageTypes() { + drupal_static_reset('language_types_info'); + foreach (language_types_info() as $type => $info) { + if (isset($info['fixed'])) { + $negotiation = variable_get("language_negotiation_$type", array()); + $equal = count($info['fixed']) == count($negotiation); + while ($equal && list($id) = each($negotiation)) { + list(, $info_id) = each($info['fixed']); + $equal = $info_id == $id; + } + $this->assertTrue($equal, t('language negotiation for %type is properly set up', array('%type' => $type))); + } + } + } +} diff --git a/modules/locale/tests/locale_test.module b/modules/locale/tests/locale_test.module index f256b5c86..14a2588dd 100644 --- a/modules/locale/tests/locale_test.module +++ b/modules/locale/tests/locale_test.module @@ -27,14 +27,89 @@ function locale_test_boot() { } } +/** + * Implements hook_init(). + */ +function locale_test_init() { + locale_test_store_language_negotiation(); +} + +/** + * Implements hook_language_types_info(). + */ +function locale_test_language_types_info() { + if (variable_get('locale_test_language_types', FALSE)) { + return array( + 'test_language_type' => array( + 'name' => t('Test'), + 'description' => t('A test language type.'), + ), + 'fixed_test_language_type' => array( + 'fixed' => array('test_language_provider'), + ), + ); + } +} + /** * Implements hook_language_types_info_alter(). */ function locale_test_language_types_info_alter(array &$language_types) { if (variable_get('locale_test_content_language_type', FALSE)) { - $language_types[LANGUAGE_TYPE_CONTENT] = array( - 'name' => t('Content'), - 'description' => t('Order of language detection methods for content. If a version of content is available in the detected language, it will be displayed.'), + unset($language_types[LANGUAGE_TYPE_CONTENT]['fixed']); + } +} + +/** + * Implements hook_language_negotiation_info(). + */ +function locale_test_language_negotiation_info() { + if (variable_get('locale_test_language_negotiation_info', FALSE)) { + $info = array( + 'callbacks' => array( + 'language' => 'locale_test_language_provider', + ), + 'file' => drupal_get_path('module', 'locale_test') .'/locale_test.module', + 'weight' => -10, + 'description' => t('This is a test language provider.'), ); + + return array( + 'test_language_provider' => array( + 'name' => t('Test'), + 'types' => array(LANGUAGE_TYPE_CONTENT, 'test_language_type', 'fixed_test_language_type'), + ) + $info, + 'test_language_provider_ts' => array( + 'name' => t('Type-specific test'), + 'types' => array('test_language_type'), + ) + $info, + ); + } +} + +/** + * Implements hook_language_negotiation_info_alter(). + */ +function locale_test_language_negotiation_info_alter(array &$language_providers) { + if (variable_get('locale_test_language_negotiation_info_alter', FALSE)) { + unset($language_providers[LOCALE_LANGUAGE_NEGOTIATION_INTERFACE]); } } + +/** + * Store the last negotiated languages. + */ +function locale_test_store_language_negotiation() { + $last = array(); + foreach (language_types() as $type) { + $last[$type] = $GLOBALS[$type]->language; + } + variable_set('locale_test_language_negotiation_last', $last); +} + +/** + * Test language provider. + */ +function locale_test_language_provider($languages) { + return 'it'; +} -- cgit v1.2.3 From c09a969d96e70cd2470849fc159d74f54b552181 Mon Sep 17 00:00:00 2001 From: webchick Date: Tue, 24 May 2011 14:00:57 -0700 Subject: Issue #1021270 by larowlan, wojtha: Fixed Blocks for custom menus are impossible to theme. --- modules/block/block.module | 10 +++++++++- modules/block/block.test | 42 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) (limited to 'modules') diff --git a/modules/block/block.module b/modules/block/block.module index 2f7e372fe..73eba3311 100644 --- a/modules/block/block.module +++ b/modules/block/block.module @@ -943,7 +943,15 @@ function template_preprocess_block(&$variables) { $variables['theme_hook_suggestions'][] = 'block__' . $variables['block']->region; $variables['theme_hook_suggestions'][] = 'block__' . $variables['block']->module; - $variables['theme_hook_suggestions'][] = 'block__' . $variables['block']->module . '__' . $variables['block']->delta; + // Hyphens (-) and underscores (_) play a special role in theme suggestions. + // Theme suggestions should only contain underscores, because within + // drupal_find_theme_templates(), underscores are converted to hyphens to + // match template file names, and then converted back to underscores to match + // pre-processing and other function names. So if your theme suggestion + // contains a hyphen, it will end up as an underscore after this conversion, + // and your function names won't be recognized. So, we need to convert + // hyphens to underscores in block deltas for the theme suggestions. + $variables['theme_hook_suggestions'][] = 'block__' . $variables['block']->module . '__' . strtr($variables['block']->delta, '-', '_'); // Create a valid HTML ID and make sure it is unique. $variables['block_html_id'] = drupal_html_id('block-' . $variables['block']->module . '-' . $variables['block']->delta); diff --git a/modules/block/block.test b/modules/block/block.test index af118a940..022bf3830 100644 --- a/modules/block/block.test +++ b/modules/block/block.test @@ -666,3 +666,45 @@ class BlockHTMLIdTestCase extends DrupalWebTestCase { $this->assertRaw('block-block-test-test-html-id', t('HTML id for test block is valid.')); } } + + +/** + * Unit tests for template_preprocess_block(). + */ +class BlockTemplateSuggestionsUnitTest extends DrupalUnitTestCase { + public static function getInfo() { + return array( + 'name' => 'Block template suggestions', + 'description' => 'Test the template_preprocess_block() function.', + 'group' => 'Block', + ); + } + + /** + * Test if template_preprocess_block() handles the suggestions right. + */ + function testBlockThemeHookSuggestions() { + // Define block delta with underscore to be preprocessed + $block1 = new stdClass(); + $block1->module = 'block'; + $block1->delta = 'underscore_test'; + $block1->region = 'footer'; + $variables1 = array(); + $variables1['elements']['#block'] = $block1; + $variables1['elements']['#children'] = ''; + template_preprocess_block($variables1); + $this->assertEqual($variables1['theme_hook_suggestions'], array('block__footer', 'block__block', 'block__block__underscore_test'), t('Found expected block suggestions for delta with underscore')); + + // Define block delta with hyphens to be preprocessed. Hyphens should be + // replaced with underscores. + $block2 = new stdClass(); + $block2->module = 'block'; + $block2->delta = 'hyphen-test'; + $block2->region = 'footer'; + $variables2 = array(); + $variables2['elements']['#block'] = $block2; + $variables2['elements']['#children'] = ''; + template_preprocess_block($variables2); + $this->assertEqual($variables2['theme_hook_suggestions'], array('block__footer', 'block__block', 'block__block__hyphen_test'), t('Hyphens (-) in block delta were replaced by underscore (_)')); + } +} -- cgit v1.2.3 From ee61c8eacd3efde0a0e23dafd6bdc885e9555efa Mon Sep 17 00:00:00 2001 From: webchick Date: Tue, 24 May 2011 15:50:37 -0700 Subject: Issue #1137074 by plach, Jose Reyero: notices and wrong links on translation tab for unpublished nodes. --- modules/translation/translation.pages.inc | 6 +++--- modules/translation/translation.test | 10 +++++++++- 2 files changed, 12 insertions(+), 4 deletions(-) (limited to 'modules') diff --git a/modules/translation/translation.pages.inc b/modules/translation/translation.pages.inc index 102d1b882..7e4f0af26 100644 --- a/modules/translation/translation.pages.inc +++ b/modules/translation/translation.pages.inc @@ -37,12 +37,12 @@ function translation_node_overview($node) { $translation_node = node_load($translations[$langcode]->nid); $path = 'node/' . $translation_node->nid; $links = language_negotiation_get_switch_links($type, $path); - $title = empty($links->links[$langcode]) ? l($translation_node->title, $path) : l($translation_node->title, $links->links[$langcode]['href'], $links->links[$langcode]); + $title = empty($links->links[$langcode]['href']) ? l($translation_node->title, $path) : l($translation_node->title, $links->links[$langcode]['href'], $links->links[$langcode]); if (node_access('update', $translation_node)) { $text = t('edit'); $path = 'node/' . $translation_node->nid . '/edit'; $links = language_negotiation_get_switch_links($type, $path); - $options[] = empty($links->links[$langcode]) ? l($text, $path) : l($text, $links->links[$langcode]['href'], $links->links[$langcode]); + $options[] = empty($links->links[$langcode]['href']) ? l($text, $path) : l($text, $links->links[$langcode]['href'], $links->links[$langcode]); } $status = $translation_node->status ? t('Published') : t('Not published'); $status .= $translation_node->translate ? ' - ' . t('outdated') . '' : ''; @@ -58,7 +58,7 @@ function translation_node_overview($node) { $path = 'node/add/' . str_replace('_', '-', $node->type); $links = language_negotiation_get_switch_links($type, $path); $query = array('query' => array('translation' => $node->nid, 'target' => $langcode)); - $options[] = empty($links->links[$langcode]) ? l($text, $path, $query) : l($text, $links->links[$langcode]['href'], array_merge_recursive($links->links[$langcode], $query)); + $options[] = empty($links->links[$langcode]['href']) ? l($text, $path, $query) : l($text, $links->links[$langcode]['href'], array_merge_recursive($links->links[$langcode], $query)); } $status = t('Not translated'); } diff --git a/modules/translation/translation.test b/modules/translation/translation.test index fa8c6b63f..54b53d9fd 100644 --- a/modules/translation/translation.test +++ b/modules/translation/translation.test @@ -20,7 +20,7 @@ class TranslationTestCase extends DrupalWebTestCase { parent::setUp('locale', 'translation', 'translation_test'); // Setup users. - $this->admin_user = $this->drupalCreateUser(array('bypass node access', 'administer nodes', 'administer languages', 'administer content types', 'administer blocks', 'access administration pages')); + $this->admin_user = $this->drupalCreateUser(array('bypass node access', 'administer nodes', 'administer languages', 'administer content types', 'administer blocks', 'access administration pages', 'translate content')); $this->translator = $this->drupalCreateUser(array('create page content', 'edit own page content', 'translate content')); $this->drupalLogin($this->admin_user); @@ -67,6 +67,14 @@ class TranslationTestCase extends DrupalWebTestCase { $node_body = $this->randomName(); $node = $this->createPage($node_title, $node_body, 'en'); + // Unpublish the original node to check that this has no impact on the + // translation overview page, publish it again afterwards. + $this->drupalLogin($this->admin_user); + $this->drupalPost('node/' . $node->nid . '/edit', array('status' => FALSE), t('Save')); + $this->drupalGet('node/' . $node->nid . '/translate'); + $this->drupalPost('node/' . $node->nid . '/edit', array('status' => NODE_PUBLISHED), t('Save')); + $this->drupalLogin($this->translator); + // Check that the "add translation" link uses a localized path. $languages = language_list(); $this->drupalGet('node/' . $node->nid . '/translate'); -- cgit v1.2.3 From 316bd96ebff36284f5f3e33268760ff9c672b6f8 Mon Sep 17 00:00:00 2001 From: webchick Date: Wed, 25 May 2011 13:07:13 -0700 Subject: Drupal 7.1 --- modules/color/color.install | 15 +++++++++++++++ modules/color/color.module | 13 +++++++++++++ modules/file/file.module | 2 +- 3 files changed, 29 insertions(+), 1 deletion(-) (limited to 'modules') diff --git a/modules/color/color.install b/modules/color/color.install index 0655e797e..ff1e835a4 100644 --- a/modules/color/color.install +++ b/modules/color/color.install @@ -41,3 +41,18 @@ function color_requirements($phase) { return $requirements; } + +/** + * Warn site administrator if unsafe CSS color codes are found in the database. + */ +function color_update_7001() { + $theme_palettes = db_query("SELECT name FROM {variable} WHERE name LIKE 'color_%_palette'")->fetchCol(); + foreach ($theme_palettes as $name) { + $palette = variable_get($name, array()); + foreach ($palette as $key => $color) { + if (!preg_match('/^#([a-f0-9]{3}){1,2}$/iD', $color)) { + drupal_set_message('Some of the custom CSS color codes specified via the color module are invalid. Please examine the themes which are making use of the color module at the Appearance settings page to verify their CSS color values.', 'warning'); + } + } + } +} diff --git a/modules/color/color.module b/modules/color/color.module index d94cadc33..ab8fb9b79 100644 --- a/modules/color/color.module +++ b/modules/color/color.module @@ -43,6 +43,7 @@ function color_form_system_theme_settings_alter(&$form, &$form_state) { '#theme' => 'color_scheme_form', ); $form['color'] += color_scheme_form($form, $form_state, $theme); + $form['#validate'][] = 'color_scheme_form_validate'; $form['#submit'][] = 'color_scheme_form_submit'; } } @@ -271,6 +272,18 @@ function theme_color_scheme_form($variables) { return $output; } +/** + * Validation handler for color change form. + */ +function color_scheme_form_validate($form, &$form_state) { + // Only accept hexadecimal CSS color strings to avoid XSS upon use. + foreach ($form_state['values']['palette'] as $key => $color) { + if (!preg_match('/^#([a-f0-9]{3}){1,2}$/iD', $color)) { + form_set_error('palette][' . $key, t('%name must be a valid hexadecimal CSS color value.', array('%name' => $form['color']['palette'][$key]['#title']))); + } + } +} + /** * Submit handler for color change form. */ diff --git a/modules/file/file.module b/modules/file/file.module index 13a8024b2..3b6e18580 100644 --- a/modules/file/file.module +++ b/modules/file/file.module @@ -976,7 +976,7 @@ function file_get_file_references($file, $field = NULL, $age = FIELD_LOAD_REVISI } } - return isset($field) ? $references[$field['field_name']] : $references; + return isset($field) ? $references[$field['field_name']] : array_filter($references); } /** -- cgit v1.2.3