1
0
mirror of https://github.com/processwire/processwire.git synced 2025-08-11 17:24:46 +02:00

Add @LostKobrakai PR #52 - Implement a way for modules to supply translations, plus some related updates

Co-authored-by: LostKobrakai <benni@kobrakai.de>
This commit is contained in:
Ryan Cramer
2021-07-02 12:38:22 -04:00
parent 15d2982dcd
commit ccc3d1f5bb
6 changed files with 317 additions and 58 deletions

View File

@@ -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')
*

View File

@@ -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);
}
/**

View File

@@ -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 ProcessWires 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

View File

@@ -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 {

View File

@@ -253,9 +253,9 @@ class ProcessLanguage extends ProcessPageType {
$out =
"<div class='InputfieldFileData InputfieldFileLanguageInfo'>" .
"<span class='InputfieldFileLanguageFilename description'>/$file &#8212;</span> <span class='notes'>$message</span> " .
"<a class='action' href='{$translationUrl}edit/?language_id={$page->id}&amp;textdomain=$textdomain'>&nbsp; " .
"<i class='fa fa-edit'></i> $editLabel <i class='fa fa-angle-double-right hover-only'></i></a>" .
"<span class='InputfieldFileLanguageFilename description'>/$file &#8212;</span> <span class='notes'>$message</span> " .
"<a class='action' href='{$translationUrl}edit/?language_id={$page->id}&amp;textdomain=$textdomain'>&nbsp; " .
"<i class='fa fa-edit'></i> $editLabel <i class='fa fa-angle-double-right hover-only'></i></a>" .
"</div>";
$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());
}

View File

@@ -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 .= "<span class='notes requires'>" . $this->labels['requires'] . " - " . implode(', ', $requires) . "</span>";
}
} else $requires = array();
$nsClassName = $modules->getModuleClass($name, true);
if(!wireInstanceOf($nsClassName, 'Module')) {
$summary .= "<span class='notes requires'>" . $this->_('Module class must implement the “ProcessWire\Module” interface.') . "</span>";
$requires[] = 'Module interface';
}
if(count($info['installs'])) {
$summary .= "<span class='detail installs'>" . $this->labels['installs'] . " - " . implode(', ', $info['installs']) . "</span>";
@@ -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 .= " - <a href='{$config->urls->admin}module/translation/?name=$moduleName'>" . $this->_('install translations') . "</a>";
$table->row(array($this->_x('Languages', 'edit'), $languages));
}
if(!empty($moduleInfo['href'])) {
$table->row(array($this->_x('More Information', 'edit'), "<a target='_blank' class='label' href='$moduleInfo[href]'>$moduleInfo[href]</a>"));
@@ -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
*