Skip to content

Instantly share code, notes, and snippets.

@aiphee
Last active June 27, 2020 09:43
Show Gist options
  • Save aiphee/048fd99a7480a8cc7dbacc93408eee85 to your computer and use it in GitHub Desktop.
Save aiphee/048fd99a7480a8cc7dbacc93408eee85 to your computer and use it in GitHub Desktop.
CakePhp manage pot translations simple gui

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.

Not working out of box yet

<?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'
);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment