features:
- all translations from
.po
files in locale subdirs - for each translation domain. - supports plural forms
- merging tool from source
.pot
which keeps already translated items - simple text logging available.
<?php | |
/** | |
* @var AppView $this | |
* @var string $localeToTranslate | |
* @var string $domainToTranslate | |
* @var string[] $domains | |
* @var Sepia\PoParser\Catalog\CatalogArray $translations | |
*/ | |
use App\Application; | |
use App\Model\Entity\User; | |
use App\View\AppView; | |
if ($this->user['role'] === User::ROLE_SUPERADMIN) { | |
echo $this->CustomForm->postLink( | |
__('Regenerate active .po from .pot file'), | |
['action' => 'rewriteCountryPos', $localeToTranslate, $domainToTranslate], | |
[ | |
'confirm' => __('This will merge this file with original .pot, may be little destructive'), | |
'class' => 'button tiny warning' | |
] | |
); | |
echo $this->CustomForm->postLink( | |
__('Regenerate all .po from .pot files'), | |
['action' => 'regenerateAll'], | |
[ | |
'confirm' => __('This will merge this file with original .pot, may be little destructive'), | |
'class' => 'button tiny warning' | |
] | |
); | |
echo $this->Html->link( | |
__('Show log'), | |
['action' => 'translationsLog'], | |
['class' => 'button tiny warning'] | |
); | |
} ?> | |
<ul class="menu expanded"> | |
<?php foreach (Application::ALL_LOCALES as $code => $locale) { ?> | |
<li> | |
<?= $this->Html->link( | |
$locale, | |
[$code, $domainToTranslate], | |
['class' => 'button tiny ' . ($code === $localeToTranslate ? 'primary' : 'secondary')] | |
) ?> | |
</li> | |
<?php } ?> | |
</ul> | |
<ul class="menu expanded"> | |
<?php foreach ($domains as $domain) { ?> | |
<li> | |
<?= $this->Html->link( | |
$domain, | |
[$localeToTranslate, $domain], | |
['class' => 'button tiny ' . ($domain === $domainToTranslate ? 'primary' : 'secondary')] | |
) ?> | |
</li> | |
<?php } ?> | |
</ul> | |
<?php | |
foreach ($translations->getEntries() as $entry) { | |
$msgId = $entry->getMsgId(); | |
$msgIdPlural = $entry->getMsgIdPlural(); | |
$msgInCurrentLocale = __d($domain, $msgId); | |
?> | |
<fieldset class="fieldset"> | |
<legend><?= $msgId ?><small>(<?= $msgIdPlural ?: '' ?>)</small></legend> | |
<?php if ($msgId !== $msgInCurrentLocale) { ?> | |
<label> | |
<span><?= __('Message in currently selected language') ?></span> | |
<input type="text" disabled value="<?= $msgInCurrentLocale ?>"> | |
</label> | |
<?php } ?> | |
<?php if ($comments = $entry->getDeveloperComments()) { | |
echo "<blockquote>$comments</blockquote>"; | |
} ?> | |
<?php if ($comments = $entry->getTranslatorComments()) { | |
echo '<blockquote>' . implode("\n<br>", $comments) . '</blockquote>'; | |
} ?> | |
<?php if ($references = $entry->getReference()) { | |
echo '<pre>' . implode("\n", $references) . '</pre>'; | |
} ?> | |
<?php | |
$common = [ | |
'data-id' => $msgId, | |
'rows' => 3, | |
'type' => 'textarea', | |
'label' => false | |
]; | |
?> | |
<?php $msgStrPlurals = $entry->getMsgStrPlurals(); | |
if (count($msgStrPlurals) > 0) { | |
foreach ($msgStrPlurals as $count => $msgStrPlural) { | |
$isLast = $count + 1 === count($msgStrPlurals); | |
echo '<label><span>' . _('In count of') . ' ' . $count . ($isLast ? '+' : '') . '</span>'; | |
echo $this->CustomForm->control( | |
$count, | |
['value' => $msgStrPlural] + $common | |
); | |
echo '</label>'; | |
} | |
} else { ?> | |
<label><span><?= __('Translation') ?></span> | |
<?= $this->CustomForm->control( | |
'singular', | |
['value' => $entry->getMsgStr()] + $common | |
) ?> | |
</label> | |
<?php } ?> | |
</fieldset> | |
<?php } ?> | |
<section class="tools callout"> | |
<dl> | |
<dd><?= __('Translated count') ?></dd> | |
<dt> | |
<span id="translated-count">xxx</span> | |
/ | |
<span id="all-count">xxx</span> | |
</dt> | |
</dl> | |
<button class="button" id="goto-next-empty"><?= __('Next untranslated') ?></button> | |
</section> | |
<style> | |
fieldset label { | |
display: flex; | |
} | |
fieldset label span { | |
flex-basis: 30%; | |
} | |
fieldset label > div { | |
flex-grow: 1; | |
} | |
.input.textarea textarea.dirty { | |
border-color: palevioletred; | |
} | |
.tools { | |
position: fixed; | |
top: 5rem; | |
right: 0; | |
} | |
dt { | |
width: 15%; | |
display: inline-block; | |
} | |
dd { | |
width: 80%; | |
display: inline-block; | |
} | |
</style> | |
<script nonce="<?= $this->nonce ?? '' ?>"> | |
/** | |
* Return hash of string | |
* @returns {string} | |
*/ | |
String.prototype.hashCode = function () { | |
var hash = 5381, i = this.length | |
while (i) | |
hash = (hash * 33) ^ this.charCodeAt(--i) | |
return (hash >>> 0).toString(); | |
} | |
const textAreas = document.querySelectorAll('.input.textarea textarea'); | |
const updateTranslatedCount = function () { | |
const inputs = document.querySelectorAll('[name="singular"]'); | |
const all = inputs.length; | |
const translated = [...inputs].filter(input => input.value.trim()).length; | |
document.getElementById('translated-count').textContent = translated; | |
document.getElementById('all-count').textContent = all; | |
}; | |
updateTranslatedCount(); | |
[...textAreas].forEach(item => { | |
item.dataset.hash = item.value.hashCode(); // Prefill each string hash to determine if it changed | |
item.addEventListener('input', e => { | |
let target = e.target; | |
if (target.dataset.hash !== target.value.hashCode()) { // When changed indicate | |
target.classList.add('dirty'); | |
} else { | |
target.classList.remove('dirty'); | |
} | |
}); | |
item.addEventListener('blur', e => { | |
let target = e.target; | |
if (!target.classList.contains('dirty')) { | |
return; // Did not change | |
} | |
fetch(location.href, { | |
method: 'POST', | |
headers: { | |
'Content-Type': 'application/json', | |
'X-CSRF-Token': '<?= $this->request->getParam('_csrfToken'); ?>' | |
}, | |
body: JSON.stringify({ | |
text: target.value, | |
field: target.name, | |
id: target.dataset.id | |
}) | |
}) | |
.then(response => response.json()) | |
.then((data) => { | |
toastr.success('Zpráva uložena'); | |
updateTranslatedCount(); | |
target.dataset.hash = target.value.hashCode(); | |
target.classList.remove('dirty'); | |
}) | |
.catch((error) => { | |
console.log(error); | |
alert('<?= __('Error, write to admin please') ?>'); | |
}); | |
}); | |
}); | |
/** | |
* Next untranslated focus | |
*/ | |
document.getElementById('goto-next-empty').addEventListener('click', ev => { | |
const untranslated = document.querySelector('[name="singular"]:empty:not(.skip)'); | |
if(!untranslated) { // Cycle | |
[...document.querySelectorAll('[name="singular"]')].forEach(el => el.classList.remove('skip')); | |
} else { | |
untranslated.scrollIntoView() | |
untranslated.focus(); | |
untranslated.classList.add('skip'); | |
} | |
}); | |
</script> | |
<?php | |
/** | |
* @var \App\View\AppView $this | |
* @var string $logContents | |
*/ | |
?> | |
<?= $this->Html->link(__('Go at translations'), ['action' => 'index'], ['class' => 'button']) ?> | |
<?php | |
$pattern = '/(\d{4}-\d{2}-\d{2}.+:)/'; | |
$parts = preg_split($pattern, $logContents, -1, PREG_SPLIT_DELIM_CAPTURE); | |
$parts = array_reverse($parts); | |
?> | |
<?php | |
foreach ($parts as $part) { | |
if (preg_match($pattern, $part)) { | |
echo $part . '<br>'; | |
} else { | |
echo '<pre>'. $part . '</pre><hr>'; | |
} | |
} | |
?> |
<?php | |
namespace App\Controller\Admin; | |
use App\Application; | |
use App\Controller\AppController; | |
use App\Model\Entity\User; | |
use Cake\Filesystem\File; | |
use Cake\Filesystem\Folder; | |
use Cake\Http\Response; | |
use Cake\I18n\I18n; | |
use Cake\Log\Engine\FileLog; | |
use Cake\Log\Log; | |
use Cake\Routing\Router; | |
use Sepia\PoParser\Parser; | |
use Sepia\PoParser\PoCompiler; | |
use Sepia\PoParser\SourceHandler\FileSystem; | |
/** | |
* Translations Controller | |
*/ | |
class TranslationsController extends AppController | |
{ | |
public function isAuthorized($user = null) | |
{ | |
if($user['role'] === User::ROLE_TRANSLATOR) { | |
return true; | |
} | |
return parent::isAuthorized($user); | |
} | |
/** | |
* @var array | |
*/ | |
private $domains = []; | |
public function initialize() { | |
parent::initialize(); | |
$dir = new Folder(APP . 'Locale/'); | |
foreach ($dir->find('.*\.pot') as $item) { | |
$this->domains[] = basename($item, '.pot'); | |
} | |
$this->modelClass = false; | |
} | |
public function regenerateAll(): void | |
{ | |
foreach (Application::ALL_LOCALES as $locale => $name) { | |
foreach ($this->domains as $domain) { | |
$this->rewriteCountryPos($locale, $domain); | |
} | |
} | |
$this->Flash->success('OK'); | |
$this->redirect(['action' => 'index']); | |
} | |
public function index(?string $localeToTranslate = null, ?string $domainToTranslate = null) | |
{ | |
if($redir = $this->checkParams($localeToTranslate, $domainToTranslate)) { | |
return $redir; | |
} | |
$poPath = APP . "Locale/$localeToTranslate/$domainToTranslate.po"; | |
try { | |
$translations = Parser::parseFile($poPath); | |
} catch (\Exception $e) { | |
throw new \RuntimeException(__('File for this domain and locale is missing')); | |
} | |
if ($this->request->is('POST')) { | |
$newText = $this->request->getData('text'); | |
$field = $this->request->getData('field'); | |
$id = $this->request->getData('id'); | |
$entry = $translations->getEntry($id); | |
if ($field === 'singular') { | |
$oldText = $entry->getMsgStr(); | |
$entry->setMsgStr($newText); | |
} else { | |
$key = $field; | |
$plurals = $entry->getMsgStrPlurals(); | |
$oldText = $plurals[$key]; | |
$plurals[$key] = $newText; | |
$entry->setMsgStrPlurals($plurals); | |
} | |
$fileHandler = new FileSystem($poPath); | |
$compiler = new PoCompiler(); | |
$fileHandler->save($compiler->compile($translations)); | |
$this->logChange($id, $field, $oldText, $newText); | |
return $this->_ajaxResponse(); | |
} | |
$this->set([ | |
'localeToTranslate' => $localeToTranslate, | |
'domains' => $this->domains, | |
'domainToTranslate' => $domainToTranslate, | |
'translations' => $translations | |
]); | |
} | |
public function translationsLog() | |
{ | |
$logContents = file_get_contents(LOGS . 'translations.log'); | |
$this->set(compact('logContents')); | |
} | |
/** | |
* Takes .po file and merges with new content from original .pot file | |
*/ | |
public function rewriteCountryPos(string $toTranslate, string $domainToTranslate) | |
{ | |
if (!array_key_exists($toTranslate, Application::ALL_LOCALES)) { | |
throw new \RuntimeException(__('Invalid locale')); | |
} | |
if (!in_array($domainToTranslate, $this->domains, true)) { | |
throw new \RuntimeException(__('Invalid domain')); | |
} | |
$base = Parser::parseFile(APP . "Locale/$domainToTranslate.pot"); | |
$translatedPoPath = APP . "Locale/$toTranslate/$domainToTranslate.po"; | |
$currentTranslations = Parser::parseFile($translatedPoPath); | |
$origEntries = $base->getEntries(); | |
foreach ($currentTranslations->getEntries() as $translationKey => $translation) { | |
$origEntry = $origEntries[$translationKey] ?? null; | |
if ($origEntry) { | |
$translation->setDeveloperComments($origEntry->getDeveloperComments()); | |
$translation->setTranslatorComments($origEntry->getTranslatorComments()); | |
$translation->setReference($origEntry->getReference()); | |
} | |
$base->addEntry($translation); | |
} | |
$base->addHeaders($currentTranslations->getHeader()); | |
$fileHandler = new FileSystem($translatedPoPath); | |
$compiler = new PoCompiler(); | |
$fileHandler->save($compiler->compile($base)); | |
$this->Flash->success(__('Successfully saved')); | |
$this->redirect($this->referer()); | |
} | |
private function checkParams(?string $localeToTranslate, ?string $domainToTranslate): ?Response | |
{ | |
if ( | |
!$localeToTranslate | |
|| | |
!file_exists(APP . "Locale/$localeToTranslate/$domainToTranslate.po") | |
|| | |
!in_array($domainToTranslate, $this->domains, true) | |
) { | |
$this->Flash->success("Locale/$localeToTranslate/$domainToTranslate.po"); | |
return $this->redirect(['action' => 'index', I18n::getDefaultLocale (), reset($this->domains)]); | |
} | |
if (!array_key_exists($localeToTranslate, Application::ALL_LOCALES)) { | |
throw new \RuntimeException(__('Invalid locale')); | |
} | |
return null; | |
} | |
private function logChange(string $id, string $field, ?string $oldText, ?string $newText): void | |
{ | |
$username = $this->Auth->user()['username']; | |
Log::setConfig('translations', [ | |
'className' => FileLog::class, | |
'path' => LOGS, | |
'file' => 'translations', | |
'scopes' => ['translations'], | |
]); | |
Log::debug( | |
<<<TEXT | |
$username: $id - $field | |
``` | |
$oldText | |
``` | |
=> | |
``` | |
$newText | |
``` | |
TEXT | |
, | |
'translations' | |
); | |
} | |
} |