diff --git a/core/modules/locale/config/locale.settings.yml b/core/modules/locale/config/locale.settings.yml index d606901..cbaa604 100644 --- a/core/modules/locale/config/locale.settings.yml +++ b/core/modules/locale/config/locale.settings.yml @@ -1,3 +1,5 @@ translation: + check_mode: 'all' check_disabled_modules: false + default_filename: '%project-%version.%language.po' default_server_pattern: 'http://ftp.drupal.org/files/translations/%core/%project/%project-%version.%language.po' diff --git a/core/modules/locale/lib/Drupal/locale/Tests/LocaleCompareTest.php b/core/modules/locale/lib/Drupal/locale/Tests/LocaleCompareTest.php index 2e85afa..9b8432f 100644 --- a/core/modules/locale/lib/Drupal/locale/Tests/LocaleCompareTest.php +++ b/core/modules/locale/lib/Drupal/locale/Tests/LocaleCompareTest.php @@ -15,6 +15,13 @@ class LocaleCompareTest extends WebTestBase { /** + * The path of the translations directory where local translations are stored. + * + * @var string + */ + private $tranlations_directory; + + /** * Modules to enable. * * @var array @@ -29,6 +36,67 @@ public static function getInfo() { ); } + function setUp() { + parent::setUp(); + module_load_include('compare.inc', 'locale'); + $admin_user = $this->drupalCreateUser(array('administer site configuration', 'administer languages', 'access administration pages', 'translate interface')); + $this->drupalLogin($admin_user); + $this->drupalPost('admin/config/regional/language/add', array('predefined_langcode' => 'de'), t('Add language')); + } + + /** + * Set the value of the default translations directory. + * + * @param string $path + * Path of the translations directory relative to the drupal installation + * directory. + */ + private function setTranslationsDirectory($path) { + $this->tranlations_directory = $path; + file_prepare_directory($path, FILE_CREATE_DIRECTORY); + variable_set('locale_translate_file_directory', $path); + } + + /** + * Creates a translation file and test its timestamp. + * + * @param string $path + * Path of the file relative to the public file path. + * @param string $filename + * Name of the file to create. + * @param string $timestamp + * Timestamp to set the file to. Defaults to current time. + * @param string $data + * Translation data to put into the file. Po header data will be added. + */ + private function makePoFile($path, $filename, $timestamp = NULL, $data = '') { + $timestamp = $timestamp ? $timestamp : REQUEST_TIME; + $path = 'public://' . $path; + $po_header = << 1);\\n" + +EOF; + + file_prepare_directory($path, FILE_CREATE_DIRECTORY); + $file = entity_create('file', array( + 'uid' => 1, + 'filename' => $filename, + 'uri' => $path . '/' . $filename, + 'filemime' => 'text/x-gettext-translation', + 'timestamp' => $timestamp, + 'status' => FILE_STATUS_PERMANENT, + )); + file_put_contents($file->uri, $po_header . $data); + touch(drupal_realpath($file->uri), $timestamp); + $file->save(); + } + /** * Test for translation status storage and translation status comparison. */ @@ -46,12 +114,12 @@ function testLocaleCompare() { // Make the test modules look like a normal custom module. i.e. make the // modules not hidden. locale_test_system_info_alter() modifies the project // info of the locale_test and locale_test_disabled modules. - variable_set('locale_translation_test_system_info_alter', TRUE); + state()->set('locale_translation_test_system_info_alter', TRUE); // Check if interface translation data is collected from hook_info. drupal_static_reset('locale_translation_project_list'); $projects = locale_translation_project_list(); - $this->assertEqual($projects['locale_test']['info']['interface translation server pattern'], 'core/modules/locale/test/modules/locale_test/%project-%version.%language.po', 'Interface translation parameter found in project info.'); + $this->assertEqual($projects['locale_test']['info']['interface translation server pattern'], 'core/modules/locale/test/test.%language.po', 'Interface translation parameter found in project info.'); $this->assertEqual($projects['locale_test']['name'] , 'locale_test', format_string('%key found in project info.', array('%key' => 'interface translation project'))); // Get the locale settings. @@ -69,12 +137,103 @@ function testLocaleCompare() { drupal_static_reset('locale_translation_project_list'); $projects = locale_translation_get_projects(); $this->assertEqual($projects['drupal']->name, 'drupal', 'Core project found'); - $this->assertEqual($projects['locale_test']->server_pattern, 'core/modules/locale/test/modules/locale_test/%project-%version.%language.po', 'Interface translation parameter found in project info.'); + $this->assertEqual($projects['locale_test']->server_pattern, 'core/modules/locale/test/test.%language.po', 'Interface translation parameter found in project info.'); $this->assertEqual($projects['locale_test_disabled']->status, '0', 'Disabled module found'); $config->delete('translation.check_disabled_modules'); // Return the locale test modules back to their hidden state. - variable_del('locale_translation_test_system_info_alter'); + state()->delete('locale_translation_test_system_info_alter'); } + /** + * Checks if local or remote translation sources are detected. + * + * This test requires a simulated environment for local and remote files. + * Normally remote files are located at a remote server (e.g. ftp.drupal.org). + * For testing we can not rely on this. A directory in the file system of the + * test site is designated for remote files and is addressed using an absolute + * URL. Because drupal does not allow file with a po extension to be accessed + * (denied in .htaccess) the translation files get a txt extension. An other + * directory is designated for local translation files. + * + * The translation status process by default checks the status of the + * installed projects. For testing purpose a predefined set of modules with + * fixed file names and release versions is used. Using a + * hook_locale_translation_projects_alter implementation in the locale_test + * module this custom project definition is applied. + * + * This test generates a set of local and remote translation files in their + * respective local and remote translation directory. The test check whether + * the most recent files are selected in the different check scenario's: check + * for local files only, check for remote files only, check for both local and + * remote files. + */ + function testCompareCheckLocal() { + $config = config('locale.settings'); + + // A flag is set to let the locale_test module replace the project data with + // a set of test projects. + state()->set('locale_translation_test_projects', TRUE); + + // Setup timestamps to identify old and new translation sources. + $timestamp_old = REQUEST_TIME - 100; + $timestamp_new = REQUEST_TIME; + + // Setup the environment. + $public_path = variable_get('file_public_path', conf_path() . '/files'); + $this->setTranslationsDirectory($public_path . '/local'); + $config->set('translation.default_filename', '%project-%version.%language.txt')->save(); + + // Add a number of files to the local file system to serve as remote + // translation server and match the project definitions set in + // locale_test_locale_translation_projects_alter(). + $this->makePoFile('remote/8.x/contrib_module_one', 'contrib_module_one-8.x-1.1.de.txt', $timestamp_new); + $this->makePoFile('remote/8.x/contrib_module_two', 'contrib_module_two-8.x-2.0-beta4.de.txt', $timestamp_old); + $this->makePoFile('remote/8.x/contrib_module_three', 'contrib_module_three-8.x-1.0.de.txt', $timestamp_old); + + // Add a number of files to the local file system to serve as local + // translation files and match the projct definitions set in + // locale_test_locale_translation_projects_alter(). + $this->makePoFile('local', 'contrib_module_one-8.x-1.1.de.txt', $timestamp_old); + $this->makePoFile('local', 'contrib_module_two-8.x-2.0-beta4.de.txt', $timestamp_new); + $this->makePoFile('local', 'custom_module_one.de.po', $timestamp_new); + + //debug(file_scan_directory(variable_get('file_public_path', conf_path() . '/files') . '/local', '/.*\.*/', array('recurse' => TRUE))); + //debug(file_scan_directory(variable_get('file_public_path', conf_path() . '/files') . '/remote', '/.*\.*/', array('recurse' => TRUE))); + + // Get status of translation sources at remote the location. + $config->set('translation.check_mode', LOCALE_TRANSLATION_CHECK_REMOTE)->save(); + $this->drupalGet('admin/reports/translations/check'); + $result = state()->get('locale_translation_status'); + $this->assertEqual($result['contrib_module_one']['de']->type, 'remote', 'Translation of contrib_module_one found'); + $this->assertEqual($result['contrib_module_one']['de']->timestamp, $timestamp_new, 'Translation timestamp found'); + $this->assertEqual($result['contrib_module_two']['de']->type, 'remote', 'Translation of contrib_module_two found'); + $this->assertEqual($result['contrib_module_two']['de']->timestamp, $timestamp_old, 'Translation timestamp found'); + $this->assertEqual($result['contrib_module_three']['de']->type, 'remote', 'Translation of contrib_module_three found'); + $this->assertEqual($result['contrib_module_three']['de']->timestamp, $timestamp_old, 'Translation timestamp found'); + + // Get status of translation sources at local file system. + $config->set('translation.check_mode', LOCALE_TRANSLATION_CHECK_LOCAL)->save(); + $this->drupalGet('admin/reports/translations/check'); + $result = state()->get('locale_translation_status'); + $this->assertEqual($result['contrib_module_one']['de']->type, 'local', 'Translation of contrib_module_one found'); + $this->assertEqual($result['contrib_module_one']['de']->timestamp, $timestamp_old, 'Translation timestamp found'); + $this->assertEqual($result['contrib_module_two']['de']->type, 'local', 'Translation of contrib_module_two found'); + $this->assertEqual($result['contrib_module_two']['de']->timestamp, $timestamp_new, 'Translation timestamp found'); + $this->assertEqual($result['locale_test']['de']->type, 'local', 'Translation of locale_test found'); + $this->assertEqual($result['custom_module_one']['de']->type, 'local', 'Translation of custom_module_one found'); + + // Get status of translation sources at both local and remote the locations. + $config->set('translation.check_mode', LOCALE_TRANSLATION_CHECK_ALL)->save(); + $this->drupalGet('admin/reports/translations/check'); + $result = state()->get('locale_translation_status'); + $this->assertEqual($result['contrib_module_one']['de']->type, 'remote', 'Translation of contrib_module_one found'); + $this->assertEqual($result['contrib_module_one']['de']->timestamp, $timestamp_new, 'Translation timestamp found'); + $this->assertEqual($result['contrib_module_two']['de']->type, 'local', 'Translation of contrib_module_two found'); + $this->assertEqual($result['contrib_module_two']['de']->timestamp, $timestamp_new, 'Translation timestamp found'); + $this->assertEqual($result['contrib_module_three']['de']->type, 'remote', 'Translation of contrib_module_three found'); + $this->assertEqual($result['contrib_module_three']['de']->timestamp, $timestamp_old, 'Translation timestamp found'); + $this->assertEqual($result['locale_test']['de']->type, 'local', 'Translation of locale_test found'); + $this->assertEqual($result['custom_module_one']['de']->type, 'local', 'Translation of custom_module_one. found'); + } } diff --git a/core/modules/locale/locale.api.php b/core/modules/locale/locale.api.php index b561ea0..ea1085f 100644 --- a/core/modules/locale/locale.api.php +++ b/core/modules/locale/locale.api.php @@ -28,7 +28,18 @@ * the module's folder. * @code * interface translation project = example_module - * interface translation server pattern = sites/example.com/modules/custom/example_module/%project-%version.%language.po + * interface translation server pattern = sites/all/modules/custom/example_module/%project-%version.%language.po + * @endcode + * + * Streamwrappers can be used in the server pattern definition. The interface + * translations directory (Configuration > Media > File system) can be addressed + * using the "translations://" streamwrapper. But also other streamwrappers can + * be used. + * @code + * interface translation server pattern = translations://%project-%version.%language.po + * @endcode + * @code + * interface translation server pattern = public://translations/%project-%version.%language.po * @endcode * * Multiple custom modules or themes sharing the same po file should have @@ -97,7 +108,7 @@ * @param array $projects * Project data as returned by update_get_projects(). * - * @see locale_project_list(). + * @see locale_translation_project_list(). */ function hook_locale_translation_projects_alter(&$projects) { // The translations are located at a custom translation sever. diff --git a/core/modules/locale/locale.batch.inc b/core/modules/locale/locale.batch.inc new file mode 100644 index 0000000..adfebf5 --- /dev/null +++ b/core/modules/locale/locale.batch.inc @@ -0,0 +1,195 @@ + $operations, + 'title' => t('Checking available translations'), + 'finished' => 'locale_translation_batch_status_finished', + 'error_message' => t('Error checking available interface translation updates.'), + 'file' => drupal_get_path('module', 'locale') . '/locale.batch.inc', + ); + return $batch; +} + +/** + * Batch operation callback: Check the availability of a remote po file. + * + * Checks the presence and creation time of one po file per batch process. The + * file URL and timestamp are stored. + * + * @param array $source + * A translation source object of the project for which to check the state of + * a remote po file. + * @param array $context + * The batch context array. The collected state is stored in the 'results' + * parameter of the context. +*/ +function locale_translation_batch_status_fetch_remote($source, &$context) { + // Check the translation file at the remote server and update the source + // data with the remote status. + if (isset($source->files['remote'])) { + $remote_file = $source->files['remote']; + $result = locale_translation_http_check($remote_file->url); + + // Update the file object with the result data. In case of a redirect we + // we store the resulting url. + if ($result && !empty($result->updated)) { + $remote_file->url = isset($result->redirect_url) ? $result->redirect_url : $remote_file->url; + $remote_file->timestamp = $result->updated; + $source->files['remote'] = $remote_file; + } + $context['results'][$source->name][$source->language] = $source; + } +} + +/** + * Batch operation callback: Check the availability of local po files. + * + * Checks the presence and creation time of po files in the local file system. + * The file path and the timestamp are stored. + * + * We check local files if the site is configured to check local or both local + * and remote files. But also we also check local sources if no remote file is + * defined for this source. For example for custom modules which + * bring their own po file and don't have a remote translation file. + * + * @param array $sources + * Array of translation source objects of projects for which to check the + * state of local po files. + * @param array $context + * The batch context array. The collected state is stored in the 'results' + * parameter of the context. + */ +function locale_translation_batch_status_fetch_local($sources, &$context) { + module_load_include('compare.inc', 'locale'); + $check_mode = config('locale.settings')->get('translation.check_mode'); + $check_local = $check_mode == LOCALE_TRANSLATION_CHECK_ALL || $check_mode == LOCALE_TRANSLATION_CHECK_LOCAL; + $check_remote = $check_mode == LOCALE_TRANSLATION_CHECK_REMOTE; + + // Get the status of local translation files and store the result data in the + // batch results for later processing. + foreach ($sources as $source) { + if (isset($source->files['local'])) { + if ($check_local || ($check_remote && !isset($source->files['remote']))) { + locale_translation_source_check_file($source); + } + + // If remote data was collected before, we merge it into the newly + // collected result. + if (isset($context['results'][$source->name][$source->language])) { + $source->files['remote'] = $context['results'][$source->name][$source->language]->files['remote']; + } + $context['results'][$source->name][$source->language] = $source; + } + } +} + +/** + * Batch operation callback: Compare states and store the result. + * + * In the preceding batch processes data of remote and local translation sources + * is collected. Here we compare the collected results and update the source + * object with the data of the most recent translation file. The end result is + * stored in the 'locale_translation_status' state variable. Other + * processes can collect this data after the batch process is completed. + * + * @param array $context + * The batch context array. The 'results' element contains a structured array + * of project data with languages, local and remote source data. + */ +function locale_translation_batch_status_compare(&$context) { + module_load_include('compare.inc', 'locale'); + $results = array(); + + foreach ($context['results'] as $project => $langcodes) { + foreach ($langcodes as $langcode => $source) { + $local = isset($source->files['local']) ? $source->files['local'] : NULL; + $remote = isset($source->files['remote']) ? $source->files['remote'] : NULL; + + // The available translation files are compare and data of the most recent + // file is used to update the source object. + $file = _locale_translation_source_compare($local, $remote) < 0 ? $remote : $local; + if (isset($file->timestamp)) { + $source->type = $file->type; + $source->timestamp = $file->timestamp; + $results[$project][$langcode] = $source; + } + } + } + + state()->set('locale_translation_status', $results); + state()->set('locale_translation_status_last_update', REQUEST_TIME); +} + +/** + * Batch finished callback: Set result message. + * + * @param boolean $success + * TRUE if batch succesfully completed. + * @param array $results + * Batch results. + */ +function locale_translation_batch_status_finished($success, $results) { + $t = get_t(); + if($success) { + if ($results) { + drupal_set_message(format_plural( + count($results), + 'Checked available interface translation updates for one project.', + 'Checked available interface translation updates for @count projects.' + )); + } + } + else { + drupal_set_message($t('An error occurred trying to check available interface translation updates.'), 'error'); + } +} + +/** + * Check if remote file exists and when it was last updated. + * + * @param string $url + * URL of remote file. + * @param array $headers + * HTTP request headers. + * @return stdClass + * Result object containing the HTTP request headers, response code, headers, + * data, redirect status and updated timestamp. + */ +function locale_translation_http_check($url, $headers = array()) { + $result = drupal_http_request($url, array('headers' => $headers, 'method' => 'HEAD')); + if ($result && $result->code == '200') { + $result->updated = isset($result->headers['last-modified']) ? strtotime($result->headers['last-modified']) : 0; + } + return $result; +} diff --git a/core/modules/locale/locale.compare.inc b/core/modules/locale/locale.compare.inc index 039eba3..a2c907a 100644 --- a/core/modules/locale/locale.compare.inc +++ b/core/modules/locale/locale.compare.inc @@ -6,11 +6,13 @@ */ /** - * Default location of gettext file on the translation server. + * Threshold for timestamp comparison. * - * @see locale_translation_default_translation_server(). + * Eliminates a difference between the download time and the actual .po file + * timestamp in seconds. The download time is stored in the database in + * {locale_file}.timestamp. */ -const LOCALE_TRANSLATION_DEFAULT_SERVER_PATTERN = 'http://ftp.drupal.org/files/translations/%core/%project/%project-%version.%language.po'; +const LOCALE_TRANSLATION_TIMESTAMP_THRESHOLD = 2; use Drupal\Core\Cache; @@ -254,7 +256,6 @@ function locale_translation_default_translation_server() { * - "%version": Project version. * - "%core": Project core version. * - "%language": Language code. - * - "%filename": Project file name. * * @return string * String with replaced placeholders. @@ -265,7 +266,270 @@ function locale_translation_build_server_pattern($project, $template) { '%version' => $project->version, '%core' => $project->core, '%language' => isset($project->language) ? $project->language : '%language', - '%filename' => isset($project->filename) ? $project->filename : '%filename', ); return strtr($template, $variables); } + +/** + * Check for the latest release of project translations. + * + * @param array $projects + * Projects to check (objects). + * @param string $langcodes + * Array of language codes to check for. Leave empty to check all languages. + * + * @return array + * Available sources indexed by project and language. + */ +function locale_translation_check_projects($projects, $langcodes = NULL) { + module_load_include('batch.inc', 'locale'); + + $check_remote = config('locale.settings')->get('translation.check_mode'); + if ($check_remote == LOCALE_TRANSLATION_CHECK_REMOTE || $check_remote == LOCALE_TRANSLATION_CHECK_ALL) { + // Retrieve the status of both remote and local translation sources by + // using a batch process. + locale_translation_check_projects_batch($projects, $langcodes); + } + else { + // Retrieve and save the status of local translations only. + locale_translation_check_projects_local($projects, $langcodes); + } +} + +/** + * Gets and stores the status and timestamp of remote po files. + * + * A batch process is used to check for po files at remote locations and (when + * configured) to check for po files in the local file system. The most recent + * translation source states are stored in the state variable + * 'locale_translation_status'. + * + * @params array $projects + * Array of translatable projects. + * @params array $langcodes + * Array of language codes to check for. Leave empty to check all languages. + */ +function locale_translation_check_projects_batch($projects, $langcodes = NULL) { + $langcodes = $langcodes ? $langcodes : array_keys(locale_translatable_language_list()); + $sources = array(); + foreach ($projects as $name => $project) { + foreach ($langcodes as $langcode) { + $project_clone = clone $project; + $source = locale_translation_source_build($project_clone, $langcode); + $sources[] = $source; + } + } + + // Build and set the batch process. + module_load_include('batch.inc', 'locale'); + $batch = locale_translation_batch_status_build($sources); + batch_set($batch); +} + +/** + * Check and store the status and timestamp of local po files. + * + * Only po files in the local file system and checked. Any remote translation + * sources will be ignored. Results are stored in the state variable + * 'locale_translation_status'. + * + * Projects may contain a server_pattern option containing a pattern of the + * path to the po source files. If no server_pattern is defined the default + * translation directory is checked for the po file. When a server_pattern is + * defined the specified location is checked. The server_pattern can be set in + * the module's .info file or by using hook_locale_translation_projects_alter(). + * + * @params array $projects + * Array of translatable projects. + * @params array $langcodes + * Array of language codes to check for. Leave empty to check all languages. + */ +function locale_translation_check_projects_local($projects, $langcodes = NULL) { + $langcodes = $langcodes ? $langcodes : array_keys(locale_translatable_language_list()); + $results = array(); + + // For each project and each language we check if a local po file is + // available. When found the source object is updated with the appropriate + // type and timestamp of the po file. + foreach ($projects as $name => $project) { + foreach ($langcodes as $langcode) { + $source = locale_translation_source_build($project, $langcode); + if (locale_translation_source_check_file($source)) { + $source->type = 'local'; + $source->timestamp = $source->files['local']->timestamp; + $results[$name][$langcode] = $source; + } + } + } + + state()->set('locale_translation_status', $results); + state()->set('locale_translation_status_last_update', REQUEST_TIME); +} + +/** + * Check whether a po file exists in the local filesystem. + * + * It will search in the directory set in the translation source. Which defaults + * to the "translations://" stream wrapper path. The directory may contain any + * valid stream wrapper. + * + * The "local" files property of the source object contains the definition of a + * po file we are looking for. The file name defaults to + * LOCALE_TRANSLATION_DEFAULT_FILENAME. Per project this value + * can be overridden using the server_pattern directive in the module's .info + * file or by using hook_locale_translation_projects_alter(). + * + * @param stdClass $source + * Translation source object. + * @see locale_translation_source_build() + * + * @return stdClass + * File object (filename, basename, name) updated with data of the po file. + * On success the files property of the source object is updated. + * files['local']: + * - "uri": File name and path. + * - "timestamp": Last updated time of the po file. + * FALSE if the file is not found. + */ +function locale_translation_source_check_file(&$source) { + if (isset($source->files['local'])) { + $directory = $source->files['local']->directory; + $filename = '/' . preg_quote($source->files['local']->filename) . '$/'; + + // If the directory contains a streamwrapper, it is converted to a real + // path. + if ($scheme = file_uri_scheme($directory)) { + $directory = str_replace($scheme . '://', drupal_realpath($scheme . '://'), $directory); + } + + if ($files = file_scan_directory($directory, $filename, array('key' => 'name'))) { + $file = current($files); + $source->files['local']->uri = $file->uri; + $source->files['local']->timestamp = filemtime($file->uri); + return $file; + } + } + return FALSE; +} + +/** + * Build abstract translation source. + * + * @param stdClass $project + * Project object. + * @param string $langcode + * Language code. + * @param string $filename + * File name of translation file. May contains placeholders. + * + * @return object + * Source object: + * - "project": Project name. + * - "name": Project name (inherited from project). + * - "language": Language code. + * - "core": Core version (inherited from project). + * - "version": Project version (inherited from project). + * - "project_type": Project type (inherited from project). + * - "files": Array of file objects containing properties of local and remote + * translation files. + * Other processes can add the following properties: + * - "type": Most recent file type 'remote' or 'local'. Corresponding with + * a key of the "files" array. + * - "timestamp": Timestamp of the most recent translation file. + * The "files" array contains file objects with the following properties: + * - "uri": Local file path. + * - "url": Remote file URL for downloads. + * - "directory": Directory of the local po file. + * - "filename": File name. + * - "timestamp": Timestamp of the file. + * - "keep": TRUE to keep the downloaded file. + */ +// @todo Move this file? +function locale_translation_source_build($project, $langcode, $filename = NULL) { + $source = clone $project; + $source->project = $project->name; + $source->language = $langcode; + + $filename = $filename ? $filename : config('locale.settings')->get('translation.default_filename'); + + // If the server_pattern contains a remote file path we will check both + // a remote and a local file. If the server_pattern is a local file path + // we will only check for a local file. + $files = array(); + if (_locale_translation_file_is_remote($source->server_pattern)) { + $files['remote'] = (object) array( + 'type' => 'remote', + 'filename' => locale_translation_build_server_pattern($source, basename($source->server_pattern)), + 'url' => locale_translation_build_server_pattern($source, $source->server_pattern), + ); + $files['local'] = (object) array( + 'type' => 'local', + 'directory' => 'translations://', + 'filename' => locale_translation_build_server_pattern($source, $filename), + ); + } + else { + $files['local'] = (object) array( + 'type' => 'local', + 'directory' => locale_translation_build_server_pattern($source, drupal_dirname($source->server_pattern)), + 'filename' => locale_translation_build_server_pattern($source, basename($source->server_pattern)), + ); + } + $source->files = $files; + + return $source; +} + +/** + * Determine if the source file is a remote or a local file. + * + * @param string $url + * The URL or URL pattern of the file. + * + * @return boolean + * TRUE if the $url is a remote file location. + */ +function _locale_translation_file_is_remote($url) { + $scheme = file_uri_scheme($url); + if ($scheme) { + return !drupal_realpath($scheme . '://'); + } + return FALSE; +} + +/** + * Compare two update sources, looking for the newer one. + * + * The timestamp property of the source objects are used to determine which is + * the newer one. + * + * @param stdClass $source1 + * Source object of the first translation source. + * @param stdClass $source2 + * Source object of available update. + * + * @return integer + * - "-1": $source1 < $source2 OR $source1 is missing. + * - "0": $source1 == $source2 OR both $source1 and $source2 are missing. + * - "1": $source1 > $source2 OR $source2 is missing. + */ +// @todo Move to locale.inc? +function _locale_translation_source_compare($source1, $source2) { + if (isset($source1->timestamp) && isset($source2->timestamp)) { + if (abs($source1->timestamp - $source2->timestamp) < LOCALE_TRANSLATION_TIMESTAMP_THRESHOLD) { + return 0; + } + else { + return $source1->timestamp > $source2->timestamp ? 1 : -1; + } + } + elseif (isset($source1->timestamp) && !isset($source2->timestamp)) { + return 1; + } + elseif (!isset($source1->timestamp) && isset($source2->timestamp)) { + return -1; + } + else { + return 0; + } +} diff --git a/core/modules/locale/locale.module b/core/modules/locale/locale.module index b2bfa95..b110016 100644 --- a/core/modules/locale/locale.module +++ b/core/modules/locale/locale.module @@ -69,6 +69,50 @@ const LOCALE_CUSTOMIZED = 1; /** + * Translation update mode: Use remote server only. + * + * When checking for available translation updates, only remote servers will be + * used. Any local translation file will be ignored. Custom modules and themes + * which have set a "server pattern" to use local translation files will still + * be checked. + */ +const LOCALE_TRANSLATION_CHECK_REMOTE = 'remote'; + +/** + * Translation update mode: Use local files only. + * + * When checking for available translation updates, only local files will be + * used. Any local remote server will be ignored. Also custom modules and themes + * which have set a "server pattern" to use a remote translation server will be + * ignored. + */ +const LOCALE_TRANSLATION_CHECK_LOCAL = 'local'; + +/** + * Translation update mode: Use both remote and local files. + * + * When checking for available translation updates, both local and remote files + * will be checked. + */ +const LOCALE_TRANSLATION_CHECK_ALL = 'all'; + +/** + * Default location of gettext file on the translation server. + * + * @see locale_translation_default_translation_server(). + */ +const LOCALE_TRANSLATION_DEFAULT_SERVER_PATTERN = 'http://ftp.drupal.org/files/translations/%core/%project/%project-%version.%language.po'; + +/** + * Default file name of translation files stored in the local file system. + * + * The file name containing placeholders which are also used by the server + * pattern. See locale_translation_build_server_pattern() for supported + * placeholders. + */ +const LOCALE_TRANSLATION_DEFAULT_FILENAME = '%project-%version.%language.po'; + +/** * Implements hook_help(). */ function locale_help($path, $arg) { @@ -142,6 +186,20 @@ function locale_menu() { 'type' => MENU_LOCAL_TASK, 'file' => 'locale.bulk.inc', ); + $items['admin/reports/translations'] = array( + 'title' => 'Available translation updates', + 'description' => 'Get a status report about available interface translations for your installed modules and themes.', + 'page callback' => 'locale_translation_status', + 'access arguments' => array('translate interface'), + 'file' => 'locale.pages.inc', + ); + $items['admin/reports/translations/check'] = array( + 'title' => 'Manual translation update check', + 'page callback' => 'locale_translation_manual_status', + 'access arguments' => array('translate interface'), + 'type' => MENU_CALLBACK, + 'file' => 'locale.pages.inc', + ); return $items; } @@ -227,7 +285,20 @@ function locale_language_delete($language) { cache()->delete('locale:' . $language->langcode); } -// Locale core functionality +/** + * Returns list of translatable languages. + * + * @return array + * Array of installed languages keyed by language name. English is omitted + * unless its marked as translatable. + */ +function locale_translatable_language_list() { + $languages = language_list(); + if (!locale_translate_english()) { + unset($languages['en']); + } + return $languages; +} /** * Provides interface translation services. @@ -819,10 +890,7 @@ function _locale_invalidate_js($langcode = NULL) { if (empty($langcode)) { // Invalidate all languages. - $languages = language_list(); - if (!locale_translate_english()) { - unset($languages['en']); - } + $languages = locale_translatable_language_list(); foreach ($languages as $lcode => $data) { $parsed['refresh:' . $lcode] = 'waiting'; } diff --git a/core/modules/locale/locale.pages.inc b/core/modules/locale/locale.pages.inc index a6fb751..3fe1968 100644 --- a/core/modules/locale/locale.pages.inc +++ b/core/modules/locale/locale.pages.inc @@ -441,6 +441,43 @@ function locale_translate_edit_form_submit($form, &$form_state) { } /** + * Page callback: Checks for translation updates and displays the translations status. + * + * Manually checks the translation status without the use of cron. + * + * @see locale_menu() + */ +function locale_translation_manual_status() { + module_load_include('compare.inc', 'locale'); + + locale_translation_flush_projects(); + $projects = locale_translation_get_projects(); + locale_translation_check_projects($projects); + + // Execute a batch if required. + if (batch_get()) { + batch_process('admin/reports/translations'); + } + drupal_goto('admin/reports/translations'); +} + +/** + * Page callback: Display the current translation status. + */ +function locale_translation_status() { + $languages = locale_translatable_language_list(); + if (!$languages) { + drupal_set_message(t('No translatable languages available. Add a language first.', array('@add_lanuage' => url('admin/config/regional/language'))), 'warning'); + } + +//debug(state()->get('locale_translation_status')); +//debug(format_date(state()->get('locale_translation_status_last_update'), 'long')); + + // @todo Calculate and display the translation status here. + return 'TODO: Show the transaltion status here'; +} + +/** * Default theme function for translatione edit form. */ function theme_locale_translate_edit_form_strings($variables) { diff --git a/core/modules/locale/tests/modules/locale_test/locale_test.info b/core/modules/locale/tests/modules/locale_test/locale_test.info index 1ec7508..f17a400 100644 --- a/core/modules/locale/tests/modules/locale_test/locale_test.info +++ b/core/modules/locale/tests/modules/locale_test/locale_test.info @@ -7,4 +7,4 @@ hidden = TRUE ; Definitions for interface translations. interface translation project = locale_test -interface translation server pattern = core/modules/locale/test/modules/locale_test/%project-%version.%language.po +interface translation server pattern = core/modules/locale/test/test.%language.po diff --git a/core/modules/locale/tests/modules/locale_test/locale_test.install b/core/modules/locale/tests/modules/locale_test/locale_test.install index 364f9fe..8f04e0f 100644 --- a/core/modules/locale/tests/modules/locale_test/locale_test.install +++ b/core/modules/locale/tests/modules/locale_test/locale_test.install @@ -10,5 +10,6 @@ */ function locale_test_uninstall() { // Clear variables. - variable_del('locale_translation_test_system_info_alter'); + state()->delete('locale_translation_test_system_info_alter'); + state()->delete('locale_translation_test_projects'); } diff --git a/core/modules/locale/tests/modules/locale_test/locale_test.module b/core/modules/locale/tests/modules/locale_test/locale_test.module index 4aa4d5d..c22e307 100644 --- a/core/modules/locale/tests/modules/locale_test/locale_test.module +++ b/core/modules/locale/tests/modules/locale_test/locale_test.module @@ -16,12 +16,130 @@ function locale_test_system_info_alter(&$info, $file, $type) { // By default the locale_test modules are hidden and have a project specified. // To test the module detection proces by locale_project_list() the // test modules should mimic a custom module. I.e. be non-hidden. - if (!variable_get('locale_translation_test_system_info_alter', FALSE)) { - return; + if (state()->get('locale_translation_test_system_info_alter')) { + if ($file->name == 'locale_test' || $file->name == 'locale_test_disabled') { + // Make the module appear as unhidden. + $info['hidden'] = FALSE; + } } +} + +/** + * Implements hook_locale_translation_projects_alter(). + * + * The translation status process by default checks the status of the installed + * projects. This function replaces the data of the installed modules by a + * predefined set of modules with fixed file names and release versions. Project + * names, versions, timestamps etc must be fixed because they must match the + * files created by the test script. + * + * The "locale_translation_test_projects" state variable must be set by the + * test script in order for this hook to take effect. + */ +function locale_test_locale_translation_projects_alter(&$projects) { + if (state()->get('locale_translation_test_projects')) { + + // Instead of the default ftp.drupal.org we use the file system of the test + // instance to simulate a remote file location. + $url = url(NULL, array('absolute' => TRUE)); + $remote_url = $url . variable_get('file_public_path', conf_path() . '/files') . '/remote/'; - if ($file->name == 'locale_test' || $file->name == 'locale_test_disabled') { - // Make the module appear as unhidden. - $info['hidden'] = FALSE; + // Completely replace the project data with a set of test projects. + $base_url = url(); + $files_url = variable_get('file_public_path', conf_path() . '/files'); + $projects = array ( + 'drupal' => array ( + 'name' => 'drupal', + 'info' => array ( + 'name' => 'Drupal', + 'interface translation server pattern' => $remote_url . '%core/%project/%project-%version.%language.txt', + 'package' => 'Core', + 'version' => '8.0', + 'project' => 'drupal', + '_info_file_ctime' => 1348824632, + 'datestamp' => 0, + ), + 'datestamp' => 0, + 'project_type' => 'core', + 'project_status' => TRUE, + ), + 'contrib_module_one' => array ( + 'name' => 'contrib_module_one', + 'info' => array ( + 'name' => 'Contributed module one', + 'interface translation server pattern' => $remote_url . '%core/%project/%project-%version.%language.txt', + 'package' => 'Other', + 'version' => '8.x-1.1', + 'project' => 'contrib_module_one', + 'datestamp' => '1344471537', + '_info_file_ctime' => 1348767306, + ), + 'datestamp' => '1344471537', + 'project_type' => 'module', + 'project_status' => TRUE, + ), + 'contrib_module_two' => array ( + 'name' => 'contrib_module_two', + 'info' => array ( + 'name' => 'Contributed module two', + 'interface translation server pattern' => $remote_url . '%core/%project/%project-%version.%language.txt', + 'package' => 'Other', + 'version' => '8.x-2.0-beta4', + 'project' => 'contrib_module_two', + 'datestamp' => '1344471537', + '_info_file_ctime' => 1348767306, + ), + 'datestamp' => '1344471537', + 'project_type' => 'module', + 'project_status' => TRUE, + ), + 'contrib_module_three' => array ( + 'name' => 'contrib_module_three', + 'info' => array ( + 'name' => 'Contributed module three', + 'interface translation server pattern' => $remote_url . '%core/%project/%project-%version.%language.txt', + 'package' => 'Other', + 'version' => '8.x-1.0', + 'project' => 'contrib_module_three', + 'datestamp' => '1344471537', + '_info_file_ctime' => 1348767306, + ), + 'datestamp' => '1344471537', + 'project_type' => 'module', + 'project_status' => TRUE, + ), + 'locale_test' => array ( + 'name' => 'locale_test', + 'info' => array ( + 'name' => 'Locale test', + 'interface translation project' => 'locale_test', + 'interface translation server pattern' => 'core/modules/locale/tests/test.%language.po', + 'package' => 'Other', + 'version' => NULL, + 'project' => 'locale_test', + '_info_file_ctime' => 1348767306, + 'datestamp' => 0, + ), + 'datestamp' => 0, + 'project_type' => 'module', + 'project_status' => TRUE, + ), + 'custom_module_one' => array ( + 'name' => 'custom_module_one', + 'info' => array ( + 'name' => 'Custom module one', + 'interface translation project' => 'custom_module_one', + 'interface translation server pattern' => 'translations://custom_module_one.%language.po', + 'package' => 'Other', + 'version' => NULL, + 'project' => 'custom_module_one', + '_info_file_ctime' => 1348767306, + 'datestamp' => 0, + ), + 'datestamp' => 0, + 'project_type' => 'module', + 'project_status' => TRUE, + ), + ); } }