From ccc3d1f5bb0c44f0f54580fc8ecdeaa298d7903a Mon Sep 17 00:00:00 2001 From: Ryan Cramer Date: Fri, 2 Jul 2021 12:38:22 -0400 Subject: [PATCH] Add @LostKobrakai PR #52 - Implement a way for modules to supply translations, plus some related updates Co-authored-by: LostKobrakai --- wire/core/Modules.php | 46 +++++- .../LanguageSupport/LanguageTranslator.php | 22 ++- wire/modules/LanguageSupport/Languages.php | 21 +++ .../LanguageSupport/ProcessLanguage.css | 10 +- .../LanguageSupport/ProcessLanguage.module | 146 ++++++++++++++---- .../ProcessModule/ProcessModule.module | 130 ++++++++++++++-- 6 files changed, 317 insertions(+), 58 deletions(-) diff --git a/wire/core/Modules.php b/wire/core/Modules.php index 5afb2420..a2d09949 100644 --- a/wire/core/Modules.php +++ b/wire/core/Modules.php @@ -4974,9 +4974,9 @@ class Modules extends WireArray { if(!in_array($id, $this->moduleIDs)) unset($this->modulesLastVersions[$id]); } if(count($this->modulesLastVersions)) { - $this->wire('cache')->save(self::moduleLastVersionsCacheName, $this->modulesLastVersions, WireCache::expireReserved); + $this->wire()->cache->save(self::moduleLastVersionsCacheName, $this->modulesLastVersions, WireCache::expireReserved); } else { - $this->wire('cache')->delete(self::moduleLastVersionsCacheName); + $this->wire()->cache->delete(self::moduleLastVersionsCacheName); } } @@ -5355,6 +5355,48 @@ class Modules extends WireArray { return $cnt; } + /** + * Get module language translation files + * + * @param Module|string $module + * @return array Array of translation files including full path, indexed by basename without extension + * @since 3.0.181 + * + */ + public function getModuleLanguageFiles($module) { + + $module = $this->getModuleClass($module); + if(empty($module)) return array(); + + $path = $this->wire()->config->paths($module); + if(empty($path)) return array(); + + $pathHidden = $path . '.languages/'; + $pathVisible = $path . 'languages/'; + + if(is_dir($pathVisible)) { + $path = $pathVisible; + } else if(is_dir($pathHidden)) { + $path = $pathHidden; + } else { + return array(); + } + + $items = array(); + $options = array( + 'extensions' => array('csv'), + 'recursive' => false, + 'excludeHidden' => true, + ); + + foreach($this->wire()->files->find($path, $options) as $file) { + $basename = basename($file, '.csv'); + $items[$basename] = $file; + } + + return $items; + } + /** * Enables use of $modules('ModuleName') * diff --git a/wire/modules/LanguageSupport/LanguageTranslator.php b/wire/modules/LanguageSupport/LanguageTranslator.php index 3bd9bd71..0e64cf74 100644 --- a/wire/modules/LanguageSupport/LanguageTranslator.php +++ b/wire/modules/LanguageSupport/LanguageTranslator.php @@ -170,7 +170,23 @@ class LanguageTranslator extends Wire { } else { $reflection = new \ReflectionClass($o); - $filename = $reflection->getFileName(); + $filename = $reflection->getFileName(); + + if($o instanceof Module) { + $ds = \DIRECTORY_SEPARATOR; + if(strpos($filename, "{$ds}wire{$ds}modules{$ds}") === false) { + // not a core module + $config = $this->wire()->config; + $filename = $this->wire()->files->unixFileName($filename); + if(strpos($filename, $config->urls($o)) === false && strpos($filename, "/$class/") !== false) { + // module likely in a symbolic link directory, so determine our own path for textdomain + // rather than using the one provided by ReflectionClass + list(, $filename) = explode("/$class/", $filename, 2); + $filename = $config->paths($class) . $filename; + } + } + } + $textdomain = $this->filenameToTextdomain($filename); $this->classNamesToTextdomains[$class] = $textdomain; $parentTextdomains = array(); @@ -286,7 +302,7 @@ class LanguageTranslator extends Wire { * */ public function getTranslation($textdomain, $text, $context = '') { - if($this->wire('hooks')->isHooked('LanguageTranslator::getTranslation()')) { + if($this->wire()->hooks->isHooked('LanguageTranslator::getTranslation()')) { // if method has hooks, we let them run return $this->__call('getTranslation', array($textdomain, $text, $context)); } else { @@ -474,7 +490,7 @@ class LanguageTranslator extends Wire { */ public function textdomainFileExists($textdomain) { $file = $this->getTextdomainTranslationFile($textdomain); - return is_file($file); + return file_exists($file); } /** diff --git a/wire/modules/LanguageSupport/Languages.php b/wire/modules/LanguageSupport/Languages.php index 6ea65b06..5c2d1e99 100644 --- a/wire/modules/LanguageSupport/Languages.php +++ b/wire/modules/LanguageSupport/Languages.php @@ -767,6 +767,27 @@ class Languages extends PagesType { public function get($key) { return parent::get($key); } + + /** + * Import a language translations file + * + * @param Language|string $language + * @param string $file Full path to .csv translations file + * The .csv file must be one generated by ProcessWire’s language translation tools. + * @param bool $quiet Specify true to suppress error/success notifications being generated (default=false) + * @return bool|int Returns integer with number of translations imported or boolean false on error + * @throws WireException + * @since 3.0.181 + * + */ + public function importTranslationsFile($language, $file, $quiet = false) { + if(!wireInstanceOf($language, 'Language')) $language = $this->get($language); + if(!$language || !$language->id) throw new WireException("Unknown language"); + $process = $this->wire()->modules->getModule('ProcessLanguage', array('noInit' => true)); /** @var ProcessLanguage $process */ + if(!$this->wire()->files->exists($file)) throw new WireException("Language file does not exist: $file"); + if(pathinfo($file, PATHINFO_EXTENSION) !== 'csv') throw new WireException("Language file does not have .csv extension"); + return $process->processCSV($file, $language, array('quiet' => $quiet)); + } /** * Hook to WireDatabasePDO::unknownColumnError diff --git a/wire/modules/LanguageSupport/ProcessLanguage.css b/wire/modules/LanguageSupport/ProcessLanguage.css index d7ecffcf..aa7b2edd 100644 --- a/wire/modules/LanguageSupport/ProcessLanguage.css +++ b/wire/modules/LanguageSupport/ProcessLanguage.css @@ -8,14 +8,16 @@ text-transform: none; } -.Inputfields .InputfieldFile .InputfieldFileLanguageInfo { + +.AdminThemeReno .Inputfields .InputfieldFile .InputfieldFileLanguageInfo, +.AdminThemeDefault .Inputfields .InputfieldFile .InputfieldFileLanguageInfo { position: relative; - margin-top: 0; + margin-top: 0; padding-top: 0; } -.InputfieldFileList .InputfieldFileLanguageInfo { - margin-top: -1em; +.Inputfields .InputfieldFile a.action:hover { + text-decoration: none; } .InputfieldFileList .InputfieldFileLanguageInfo a i.hover-only { diff --git a/wire/modules/LanguageSupport/ProcessLanguage.module b/wire/modules/LanguageSupport/ProcessLanguage.module index 9cc030ed..d0c62d54 100644 --- a/wire/modules/LanguageSupport/ProcessLanguage.module +++ b/wire/modules/LanguageSupport/ProcessLanguage.module @@ -253,9 +253,9 @@ class ProcessLanguage extends ProcessPageType { $out = "
" . - "/$file — $message " . - "  " . - " $editLabel " . + "/$file — $message " . + "  " . + " $editLabel " . "
"; $page->translator->unloadTextdomain($textdomain); @@ -347,27 +347,62 @@ class ProcessLanguage extends ProcessPageType { */ public function ___executeDownload() { - $id = (int) $this->input->get('language_id'); + $config = $this->wire()->config; + $input = $this->wire()->input; + + $id = (int) $input->get('language_id'); if(!$id) throw new WireException("No language specified"); - $language = $this->wire('languages')->get($id); + $language = $this->wire()->languages->get($id); if(!$language->id) throw new WireException("Unknown language"); - $fieldName = $this->input->get('field') == 'language_files_site' ? 'language_files_site' : 'language_files'; - $csv = (int) $this->wire('input')->get('csv'); + $fieldName = $input->get('field') == 'language_files_site' ? 'language_files_site' : 'language_files'; + $textdomain = $this->wire()->sanitizer->textdomain($input->get('textdomain')); + $textdomains = array(); + $csv = (int) $input->get('csv'); $path = $language->$fieldName->path(); $files = array(); - foreach($language->$fieldName as $file) { - $files[] = $file->filename; + if($textdomain) { + $file = $language->translator->textdomainToFilename($textdomain); + if($file) { + $files[] = $file; + $textdomains[$file] = $textdomain; + } else { + $textdomain = ''; + } + } + + if(!count($files) && $fieldName) { + foreach($language->$fieldName as $file) { + $files[] = $file->filename; + } + } + + if(!count($files)) { + throw new WireException('No translation files specified to download'); } if($csv) { // CSV - $filename = $language->name . "-" . (strpos($fieldName, 'site') ? 'site' : 'wire') . ".csv"; - header("Content-type: application/force-download"); - header("Content-Transfer-Encoding: Binary"); - header("Content-disposition: attachment; filename=$filename"); + if($textdomain) { + // i.e. es-modulename.csv + $parts = explode('--', $textdomain); + $basename = array_pop($parts); + $parts = explode('-', $basename); + $basename = array_shift($parts); + $filename = "$language->name-$basename.csv"; + } else { + // i.e. es-site.csv or es-wire.csv + $filename = $language->name . "-" . (strpos($fieldName, 'site') ? 'site' : 'wire') . ".csv"; + } + if($input->get('view')) { + header("Content-type: text/plain"); + } else { + header("Content-type: application/force-download"); + header("Content-Transfer-Encoding: Binary"); + header("Content-disposition: attachment; filename=$filename"); + } $fp = fopen('php://output', 'w'); $defaultCol = $language->name == 'en' ? 'default' : 'en'; @@ -375,13 +410,20 @@ class ProcessLanguage extends ProcessPageType { fputcsv($fp, $fields); foreach($files as $f) { + + if(isset($textdomains[$f])) { + $textdomain = $textdomains[$f]; + } else { + $textdomain = basename($f, '.json'); + } - $textdomain = basename($f, '.json'); $data = $language->translator->getTextdomain($textdomain); + if(empty($data)) continue; + $file = $data['file']; - $pathname = $this->wire('config')->paths->root . $file; + $pathname = $config->paths->root . $file; $translated =& $data['translations']; - $parser = $this->wire(new LanguageParser($language->translator, $pathname)); + $parser = $this->wire(new LanguageParser($language->translator, $pathname)); /** @var LanguageParser $parser */ $untranslated = $parser->getUntranslated(); $comments = $parser->getComments(); @@ -420,16 +462,26 @@ class ProcessLanguage extends ProcessPageType { * * @param string $csvFile * @param Language $language - * @return bool + * @param array $options Additional options (3.0.181+) + * - `file` (string): Use this path/file (relative to install root) + * - `quiet` (bool): Suppress generating notifications? (default=false) + * @return bool|int Returns false on error or integer on success, where value is number of translations imported * @throws WireException * */ - public function processCSV($csvFile, Language $language) { + public function processCSV($csvFile, Language $language, array $options = array()) { + + $defaults = array( + 'file' => '', + 'quiet' => false, + ); + + $options = array_merge($defaults, $options); $fp = fopen($csvFile, "r"); if($fp === false) { - $this->error($this->csvImportLabel . "Unable to open: $csvFile"); + if(!$options['quiet']) $this->error($this->csvImportLabel . "Unable to open: $csvFile"); return false; } @@ -450,8 +502,16 @@ class ProcessLanguage extends ProcessPageType { $numTotal = 0; $numGross = 0; $translations = null; + $optionsFileBasename = ''; $halt = false; + $this->wire($translator); + + if(!empty($options['file'])) { + $options['file'] = ltrim($this->wire()->files->unixFileName($options['file']), '/'); + $optionsFileBasename = basename($options['file']); + } + while(($csvData = fgetcsv($fp, 8192, ",")) !== FALSE) { if(++$n === 1) { @@ -463,13 +523,16 @@ class ProcessLanguage extends ProcessPageType { // make sure everything we need is present foreach($keys as $k => $key) { if($k > 1 && !in_array($key, $header)) { - $this->error($this->csvImportLabel . "CSV data missing required column '$key'"); - $halt = true; + if($key === 'file' && !empty($options['file'])) { + // default file provided so not required in CSV data + } else { + if(!$options['quiet']) $this->error($this->csvImportLabel . "CSV data missing required column '$key'"); + $halt = true; + } } } if($halt) break; continue; - } $row = array(); @@ -479,7 +542,22 @@ class ProcessLanguage extends ProcessPageType { $row[$name] = $csvData[$key]; } - if(empty($row['file']) || empty($row['original'])) continue; + if($options['file']) { + if(empty($row['file'])) { + $row['file'] = $options['file']; + } else { + $rowFileBasename = basename($row['file']); + if($rowFileBasename === $optionsFileBasename) { + // i.e. site/modules/Hello/Hello.module + $row['file'] = $options['file']; + } else { + // i.e. site/modules/Hello/World.module + $row['file'] = dirname($options['file']) . '/' . $rowFileBasename; + } + } + } + + if(empty($row['original']) || empty($row['file'])) continue; $file = $row['file']; $hash = $row['hash']; @@ -489,17 +567,17 @@ class ProcessLanguage extends ProcessPageType { if(!$translator->textdomainFileExists($textdomain)) { $textdomain = $translator->addFileToTranslate($file, false, false); - //$translator->loadTextdomain($textdomain); } - if(is_null($translations)) $translations = $translator->getTranslations($textdomain); + if(is_null($translations)) { + $translations = $translator->getTranslations($textdomain); + } if(!$textdomain) { - $this->warning($this->csvImportLabel . sprintf( - $this->_('Unrecognized textdomain for file: %s'), - $this->wire('sanitizer')->entities($file) - ) - ); + if(!$options['quiet']) $this->warning($this->csvImportLabel . sprintf( + $this->_('Unrecognized textdomain for file: %s'), + $this->wire()->sanitizer->entities($file) + )); continue; } @@ -529,9 +607,11 @@ class ProcessLanguage extends ProcessPageType { $language->save(); fclose($fp); - $this->message($this->csvImportLabel . sprintf($this->_('%d total translations, %d total changes'), $numGross, $numTotal)); + if(!$options['quiet']) { + $this->message($this->csvImportLabel . sprintf($this->_('%d total translations, %d total changes'), $numGross, $numTotal), Notice::noGroup); + } - return $halt ? false : true; + return $halt ? false : $numGross; } /** @@ -549,7 +629,7 @@ class ProcessLanguage extends ProcessPageType { if($numChanges) { try { $translator->saveTextdomain($textdomain); - $this->message($this->csvImportLabel . sprintf($this->_('Saved %d change(s) for file: %s'), $numChanges, $file)); + $this->message($this->csvImportLabel . sprintf($this->_('Saved %d change(s) for file: %s'), $numChanges, $file), Notice::noGroup); } catch(\Exception $e) { $this->error($e->getMessage()); } diff --git a/wire/modules/Process/ProcessModule/ProcessModule.module b/wire/modules/Process/ProcessModule/ProcessModule.module index ce8b48fa..504af829 100644 --- a/wire/modules/Process/ProcessModule/ProcessModule.module +++ b/wire/modules/Process/ProcessModule/ProcessModule.module @@ -139,6 +139,7 @@ class ProcessModule extends Process { $this->labels['download_install'] = $this->_('Download and Install'); } $this->labels['get_module_info'] = $this->_('Get Module Info'); + $this->labels['modules'] = $this->_('Modules'); $this->labels['module_information'] = $this->_x("Module Information", 'edit'); $this->labels['download_now'] = $this->_('Download Now'); $this->labels['download_dir'] = $this->_('Add Module From Directory'); @@ -197,7 +198,17 @@ class ProcessModule extends Process { * */ public function ___executeNavJSON(array $options = array()) { - $page = $this->wire('page'); + + $page = $this->wire()->page; + $input = $this->wire()->input; + $modules = $this->wire()->modules; + + $site = (int) $input->get('site'); + $core = (int) $input->get('core'); + $configurable = (int) $input->get('configurable'); + $install = (int) $input->get('install'); + $moduleNames = array(); + $data = array( 'url' => $page->url, 'label' => (string) $page->get('title|name'), @@ -205,36 +216,32 @@ class ProcessModule extends Process { 'list' => array(), ); - $site = $this->wire('input')->get('site'); - $core = $this->wire('input')->get('core'); - $configurable = $this->wire('input')->get('configurable'); - $install = $this->wire('input')->get('install'); - if($site || $install) $data['add'] = array( 'url' => "?new#tab_new_modules", 'label' => __('Add New', '/wire/templates-admin/default.php'), 'icon' => 'plus-circle', ); - $modules = $this->wire('modules'); - $moduleNames = array(); if($install) { $moduleNames = array_keys($modules->getInstallable()); } else { - foreach($modules as $module) $moduleNames[] = $module->className(); + foreach($modules as $module) { + $moduleNames[] = $module->className(); + } } + sort($moduleNames); foreach($moduleNames as $moduleName) { - $info = $this->wire('modules')->getModuleInfoVerbose($moduleName); + $info = $modules->getModuleInfoVerbose($moduleName); if($site && $info['core']) continue; if($core && !$info['core']) continue; if($configurable) { if(!$info['configurable'] || !$info['installed']) continue; - $flags = $this->wire('modules')->getFlags($moduleName); + $flags = $modules->getFlags($moduleName); if($flags & Modules::flagsNoUserConfig) continue; } @@ -242,7 +249,7 @@ class ProcessModule extends Process { // exclude already installed modules if($info['installed']) continue; // check that it can be installed NOW (i.e. all dependencies met) - if(!$this->wire('modules')->isInstallable($moduleName, true)) continue; + if(!$modules->isInstallable($moduleName, true)) continue; } $label = $info['name']; @@ -264,7 +271,8 @@ class ProcessModule extends Process { ksort($data['list']); $data['list'] = array_values($data['list']); - if($this->wire('config')->ajax) header("Content-Type: application/json"); + if($this->wire()->config->ajax) header("Content-Type: application/json"); + return json_encode($data); } @@ -863,6 +871,12 @@ class ProcessModule extends Process { $summary .= "" . $this->labels['requires'] . " - " . implode(', ', $requires) . ""; } } else $requires = array(); + + $nsClassName = $modules->getModuleClass($name, true); + if(!wireInstanceOf($nsClassName, 'Module')) { + $summary .= "" . $this->_('Module class must implement the “ProcessWire\Module” interface.') . ""; + $requires[] = 'Module interface'; + } if(count($info['installs'])) { $summary .= "" . $this->labels['installs'] . " - " . implode(', ', $info['installs']) . ""; @@ -1697,6 +1711,13 @@ class ProcessModule extends Process { if($hooksStr) { $table->row(array($this->_x('Hooks To', 'edit'), $hooksStr)); } + + $languageFiles = $languages ? $modules->getModuleLanguageFiles($moduleName) : array(); + if(count($languageFiles)) { + $languages = wireIconMarkup('language') . ' ' . $sanitizer->entities(implode(', ', array_keys($languageFiles))); + $languages .= " - " . $this->_('install translations') . ""; + $table->row(array($this->_x('Languages', 'edit'), $languages)); + } if(!empty($moduleInfo['href'])) { $table->row(array($this->_x('More Information', 'edit'), "$moduleInfo[href]")); @@ -1750,10 +1771,9 @@ class ProcessModule extends Process { public function ___executeInstallConfirm() { - $name = $this->wire('input')->get('name'); + $name = $this->wire('input')->get->name('name'); if(!$name) throw new WireException("No module name specified"); - $name = $this->wire('sanitizer')->fieldName($name); - if(!$this->wire('modules')->isInstallable($name, true)) throw new WireException("Module is not currently installable"); + if(!$this->wire()->modules->isInstallable($name, true)) throw new WireException("Module is not currently installable"); $this->headline($this->labels['install']); @@ -1778,6 +1798,84 @@ class ProcessModule extends Process { return $form->render(); } + /** + * Languages translations import + * + * @return string + * @since 3.0.181 + * + */ + public function ___executeTranslation() { + + $languages = $this->wire()->languages; + $modules = $this->wire()->modules; + $session = $this->wire()->session; + $input = $this->wire()->input; + $config = $this->wire()->config; + $moduleName = $input->get->name('name'); + + if(empty($moduleName)) throw new WireException('No module name specified'); + if(!$modules->isInstalled($moduleName)) throw new WireException("Unknown module: $moduleName"); + + $moduleEditUrl = $modules->getModuleEditUrl($moduleName); + $languageFiles = $modules->getModuleLanguageFiles($moduleName); + + if(!$languages || !count($languageFiles)){ + $session->message($this->_('No module language files available')); + $session->location($moduleEditUrl); + } + + $this->headline($this->_('Module language translations')); + $this->breadcrumb($config->urls->admin . 'modules/', $this->labels['modules']); + $this->breadcrumb($moduleEditUrl, $moduleName); + + /** @var InputfieldForm $form */ + $form = $modules->get('InputfieldForm'); + $form->attr('id', 'ModuleImportTranslationForm'); + $form->attr('action', $config->urls->admin . "module/translation/?name=$moduleName"); + $form->attr('method', 'post'); + $form->description = sprintf($this->_('Import translations for module %s'), $moduleName); + + foreach($languages as $language) { + /** @var InputfieldSelect $lang */ + $langLabel = $language->get('title'); + $langLabel .= $langLabel ? " ($language->name)" : $language->name; + + /** @var InputfieldSelect $f */ + $f = $modules->get('InputfieldSelect'); + $f->attr('name', "language_$language->name"); + $f->label = sprintf($this->_('Import into %s'), $langLabel); + $f->addOption(''); + foreach($languageFiles as $basename => $filename) { + $f->addOption($basename); + } + $form->append($f); + } + + if($input->post('submit_import_translations')) { + foreach($languages as $language) { + $basename = $input->post->pageName("language_$language->name"); + if(empty($basename)) continue; + if(empty($languageFiles[$basename])) continue; + $file = $languageFiles[$basename]; + if(!is_file($file)) { + $session->error($this->_('Cannot find CSV file') . " - " . basename($file)); + continue; + } + $languages->importTranslationsFile($language, $file); + } + $session->location($moduleEditUrl); + } + + /** @var InputfieldSubmit $f */ + $f = $modules->get('InputfieldSubmit'); + $f->attr('name', 'submit_import_translations'); + $f->showInHeader(true); + $form->add($f); + + return $form->render(); + } + /** * URL to redirect to after non-authenticated user is logged-in, or false if module does not support *