# PLUGIN PREVIEW BY TEXTPATTERN.INFO
/**
* smd_babel
*
* A Textpattern CMS plugin for managing language strings translations.
*
* @author Stef Dawson
* @link https://stefdawson.com/
*/
if (txpinterface === 'admin') {
new smd_babel();
}
/**
* Admin-side user interface.
*/
class smd_babel
{
/**
* The plugin's event as registered in Txp.
*
* @var string
*/
protected $event = 'smd_babel';
/**
* Constructor to set up callbacks and environment.
*/
public function __construct()
{
add_privs($this->event, '1,2');
register_tab('admin', $this->event, gTxt('smd_babel'));
register_callback(array($this, 'smd_babel'), $this->event);
register_callback(array($this, 'inject_css'), 'admin_side', 'head_end');
}
/**
* Plugin jumpoff point.
*
* @param string $evt Textpattern event
* @param string $stp Textpattern step (action)
*/
public function smd_babel($evt, $stp)
{
$available_steps = array(
'ui' => false,
'fetchGroup' => true,
'save' => true,
'delete' => true,
'export' => true,
);
if (!$stp or !bouncer($stp, $available_steps)) {
$stp = 'ui';
}
$this->$stp();
}
/**
* Inject style rules into the <head> of the page.
*
* @param string $evt Textpattern event (panel)
* @param string $stp Textpattern step (action)
* @return string Style rules, or nothing if not the correct $event
*/
public function inject_css($evt, $stp)
{
global $event;
if ($event === $this->event) {
$smd_babel_styles = <<<EOCSS
.smd_babel_string { width: 100%; }
.smd_babel_panel { float: right; }
.smd_babel_delete { cursor: pointer; }
textarea.smd_babel_string { min-height: auto; height: auto; }
EOCSS;
echo '<style type="text/css">' . $smd_babel_styles . '</style>';
}
return;
}
/**
* Table of language strings by group.
*
* @param string $message Flash message to display success/error
* @return string HTML
*/
public function ui($message = '')
{
pagetop(gTxt('smd_babel'), $message);
require_privs('smd_babel');
$groups = safe_column('event', 'txp_lang', '1 GROUP BY event');
$langObj = Txp::get('\Textpattern\L10n\Lang');
$defaultOwner = TEXTPATTERN_LANG_OWNER_SITE;
$activeLang = $langObj->available(TEXTPATTERN_LANG_ACTIVE);
$activeIdentifier = key($activeLang);
$siteLang = get_pref('language', TEXTPATTERN_DEFAULT_LANG, true);
$uiLang = get_pref('language_ui', $siteLang, true);
$selectedLang = get_pref('smd_babel_lang', $activeIdentifier);
$baseLang = $siteLang;
$primary = $langObj->languageSelect('smd_babel_lang_xlate', $selectedLang);
$installed = (method_exists($langObj, 'languageList')) ? $langObj->languageList() : $this->languageList();
$showUI = ($uiLang !== $baseLang);
$uiSelect = hInput('smd_babel_lang_ui', $uiLang);
$baseSelect = hInput('smd_babel_lang_site', $baseLang);
$group = get_pref('smd_babel_group', 'admin');
$groups = selectInput('smd_babel_group', $groups, $group);
$searchBlock = n.tag(
'<div class="smd_babel_panel">
<a class="txp-button smd_babel_add" href="#">' . gTxt('add') . '</a>
<a class="txp-button smd_babel_export" href="#">' . gTxt('export') . '</a>
</div>'.n.
tag(
form(
inputLabel('key', fInput('text', array('name' => 'key', 'required' => 1), ''), 'smd_babel_key').
inputLabel('group', fInput('text', array('name' => 'group', 'required' => 1), $group), 'smd_babel_group').
inputLabel('lang', selectInput('lang', $installed, $selectedLang, false), 'smd_babel_lang').
inputLabel('value', fInput('text', array('name' => 'value', 'required' => 1), ''), 'smd_babel_value').
hInput('smd_babel_added', '1').
fInput('submit', 'smd_babel_submit', gTxt('save'))
.eInput($this->event)
.sInput('save'),
'',
'',
'post',
'async'
),
'div', array(
'class' => 'smd_babel_addform',
'aria-label' => gTxt('smd_babel_add_string'),
'title' => gTxt('smd_babel_add_string'),
)).n.
tag(
form(
inputLabel('group', fInput('text', array('name' => 'group'), $group), 'smd_babel_group').
inputLabel('lang', selectInput('lang', $installed, $selectedLang, false), 'smd_babel_lang').
inputLabel('key', fInput('text', array('name' => 'key'), ''), 'smd_babel_key').
fInput('submit', 'smd_babel_submit', gTxt('download'))
.eInput($this->event)
.sInput('export'),
'',
'',
'post'
),
'div', array(
'class' => 'smd_babel_exportform',
'aria-label' => gTxt('smd_babel_export_strings'),
'title' => gTxt('smd_babel_export_strings'),
)),
'div', array(
'class' => 'txp-layout-4col-3span',
'id' => $this->event.'_control',
)
);
$pageBlock = '';
$total = 1;
$criteria = '';
// Three columns:
// 1) Keys and their base language translations.
// 2) Primary (current admin language) translations in 2nd column if base not
// in use on admin side.
// 3) Translation column with lang selector at top to load the strings from that
// lang corresponding to the currently selected group (event). Editable.
$createBlock = tag($groups, 'div', array('class' => 'txp-control-panel'));
$contentBlock = tag_start('div', array('class' => 'txp-listtables')).
n.tag_start('table', array('class' => 'txp-list')).
n.tag_start('thead').
tr(
hCell(gTxt('smd_babel_lang_site', array('{lang}' => $baseLang)).n.$baseSelect, null, array('class' => 'langCol '.$baseLang)).
(
($showUI)
? hCell(gTxt('smd_babel_lang_ui', array('{lang}' => $uiLang)).n.$uiSelect, null, array('class' => 'langCol '.$uiLang))
: ''
).
hCell(gTxt('smd_babel_lang_xlate').n.$primary, null, array('class' => 'langCol', 'width' => '40%'))
).
n.tag_end('thead').
n.tag_start('tbody', array('class' => 'smd_babel_table')).
n.tag_end('tbody').
n.tag_end('table').
n.tag_end('div');
$table = new \Textpattern\Admin\Table($this->event);
echo $table->render(compact('total', 'criteria'), $searchBlock, $createBlock, $contentBlock, $pageBlock);
echo script_js(<<<EOJS
jQuery(function() {
/**
* Group change handler.
*/
jQuery('select[name=smd_babel_group]').on('change', smd_babel_rebuild_table).change();
/**
* Language change handler.
*/
jQuery('select[name=smd_babel_lang_xlate]').on('change', smd_babel_rebuild_table).change();
/**
* Add button handlers.
*/
$(document).on('click', '.smd_babel_add', function (ev) {
ev.preventDefault();
$('.smd_babel_addform').dialog('open');
});
jQuery('.smd_babel_addform, .smd_babel_exportform').dialog({
dialogClass: 'txp-tagbuilder-container',
autoOpen: false,
focus: function (ev, ui) {
$(ev.target).closest('.ui-dialog input').focus();
}
});
/**
* Export button handlers.
*/
$(document).on('click', '.smd_babel_export', function (ev) {
ev.preventDefault();
$('.smd_babel_exportform').dialog('open');
});
/**
* Store the given string in the given language when input changes.
*/
jQuery('.smd_babel_table').on('change', 'textarea', function() {
var me = jQuery(this);
var key = me.attr('name');
var lng = me.data('lang');
var val = me.val();
var grp = jQuery('select[name=smd_babel_group]').val();
sendAsyncEvent(
{
event: textpattern.event,
step: 'save',
key: key,
lang: lng,
group: grp,
value: val
}, function (data) {
textpattern.Console.addMessage([data.msg, 0], 'smd_babel').announce('smd_babel');
},
'json');
});
/**
* Delete the given string in the given language.
*/
jQuery('.smd_babel_table').on('click', '.smd_babel_delete', function(ev) {
ev.preventDefault();
var me = jQuery(this);
var key = me.data('key');
var lng = jQuery('select[name=smd_babel_lang_xlate]').val();
var grp = jQuery('select[name=smd_babel_group]').val();
sendAsyncEvent(
{
event: textpattern.event,
step: 'delete',
key: key,
lang: lng,
group: grp
}, function (data) {
textpattern.Console.addMessage([data.msg, 0], 'smd_babel').announce('smd_babel');
me.closest('tr').remove();
},
'json');
});
/**
* Fetch new strings and reconstruct the main table body.
*/
function smd_babel_rebuild_table() {
var grp = jQuery('select[name=smd_babel_group]').val();
var langs = [];
jQuery('.langCol').each(function() {
langs.push(jQuery(this).find('input, select').val());
});
sendAsyncEvent(
{
event: textpattern.event,
step: 'fetchGroup',
group: grp,
langs: langs
}, function (data) {
var keys = [];
var owners = [];
var strings = [];
// Loop through the languages and extract matching values with translations.
jQuery.each(langs, function(idx, lng) {
strings[lng] = [];
jQuery.each(data[lng], function(key, str) {
// Assume base language contains all keys: faulty assumption in
// some cases but there's no guarantee English is installed.
if (idx === 0) {
keys.push(key);
}
owners.push(str.owner);
// Guard against languages with missing keys.
if (keys.includes(key)) {
strings[lng].push(str.data);
} else {
strings[lng].push('');
}
});
});
// Reconstruct the table.
var tbl = jQuery('.smd_babel_table');
tbl.empty();
var selectedLang = '';
jQuery.each(keys, function(idx, key) {
var row = [];
var maxLangs = langs.length - 1;
var link = '';
var canDelete = (owners[idx] === '{$defaultOwner}');
jQuery.each(langs, function(jdx, lng) {
if (jdx === maxLangs) {
// Todo: escape strings in case they contain double quotes.
selectedLang = lng;
link = '<textarea name="'+key+'" class="smd_babel_string" data-lang="'+lng+'">'+strings[lng][idx]+'</textarea>';
} else {
link = (jdx === 0 && (canDelete ? '<a class="smd_babel_delete ui-icon ui-icon-close" data-key="'+key+'">x</a> ' : '')) + strings[lng][idx] + ((jdx === 0) ? '<br/><span class="txp-form-field-instructions">' + key + '</span>': '');
}
row.push('<td>'+ link + '</td>');
});
tbl.append('<tr>'+row.join(' ')+'</tr>');
});
// Resync the language and group selectors in the Add form in case they've changed.
jQuery('.smd_babel_addform, .smd_babel_exportform').find('[name=lang]').val(selectedLang).prop('selected', true);
jQuery('.smd_babel_addform, .smd_babel_exportform').find('[name=group]').val(grp);
},
'json');
}
})
EOJS
);
}
/**
* Ajax: Fetch all Textpack strings from the given group.
*
* Requires POST variables:
* param string group The language group (event)
* param array langs The language (refs) to fetch
* @return array JSON response
*/
public function fetchGroup()
{
$grp = doSlash(ps('group'));
$lng = doSlash(ps('langs'));
if (!$lng) {
$lng = TEXTPATTERN_DEFAULT_LANG;
}
$lng = (array) $lng;
$last_lang = end($lng);
$lang_list = "lang IN (".implode(',', quote_list($lng)).")";
$rs = safe_rows('name, lang, data, owner', 'txp_lang', "event='$grp' AND $lang_list ORDER BY name");
$out = array();
foreach ($rs as $row) {
$out[$row['lang']][$row['name']] = array('data' => $row['data'], 'owner' => $row['owner']);
}
set_pref('smd_babel_group', $grp, 'smd_babel', PREF_HIDDEN, '', 0, PREF_PRIVATE);
set_pref('smd_babel_lang', $last_lang, 'smd_babel', PREF_HIDDEN, '', 0, PREF_PRIVATE);
echo json_encode($out);
}
/**
* Ajax: Save (overwrite) the given string with the new translation.
*
* Requires POST variables:
* param string key The key name to change
* param string lang The language (ref) to alter
* param string group The language group (event)
* param string value The new string value
* @return array JSON response
*/
public function save()
{
$key = ps('key');
$lng = ps('lang');
$grp = ps('group');
$val = ps('value');
$isAdd = ps('smd_babel_added');
$langObj = Txp::get('\Textpattern\L10n\Lang');
$installed = $langObj->installed();
$msg = '';
if (!in_array($lng, $installed)) {
$msg = gTxt('smd_babel_language_not_installed', array('{name}' => $lng));
} else {
$ret = safe_upsert(
'txp_lang',
array('data' => $val, 'event' => $grp, 'lastmod' => 'NOW()', 'owner' => TEXTPATTERN_LANG_OWNER_SITE),
array('name' => $key, 'lang' => $lng)
);
$this->syncKeys($key, $grp, $lng);
if ($ret) {
$msg = gTxt('smd_babel_string_updated', array('{name}' => $key));
}
}
if ($isAdd) {
// Ajax form submission.
send_script_response('textpattern.Console.addMessage(['.json_encode($msg, TEXTPATTERN_JSON).', 0], "smd_babel").announce("smd_babel");').n;
} else {
// JSON Ajax call.
echo json_encode(array('msg' => $msg));
}
return;
}
/**
* Ajax: Delete a string if it exists and is non-core.
*
* Requires POST variables:
* param string key The key name to change
* param string lang The language (ref) to alter
* param string group The language group (event)
* @return array JSON response
*/
public function delete()
{
$msg = '';
$lng = doSlash(ps('lang'));
$grp = doSlash(ps('group'));
$key = doSlash(ps('key'));
$exists = safe_field('name', 'txp_lang', "name='$key' AND lang='$lng' AND event='$grp' AND owner='" . TEXTPATTERN_LANG_OWNER_SITE . "'");
if ($exists) {
$safe_exists = doSlash($exists);
$done = safe_delete('txp_lang', "name='$safe_exists' AND lang='$lng' AND event='$grp' AND owner='" . TEXTPATTERN_LANG_OWNER_SITE . "'");
if ($done) {
$msg = gTxt('smd_babel_string_deleted', array('{name}' => $exists));
}
}
echo json_encode(array('msg' => $msg));
return;
}
/**
* Ajax: Export a string set as a file.
*
* Available POST variables:
* param string lang The language (ref) to export
* param string group The group (event) to export. Empty = all
* param string key The partial key (name) to match. Empty = ignored
*
* @return string
*/
public function export()
{
$lng = ps('lang');
$grp = ps('group');
$key = ps('key');
$grpClause = ($grp) ? " AND event IN (".implode(',', quote_list(do_list($grp))).")" : '';
$keyClause = ($key) ? " AND name LIKE '%" . doSlash($key) . "%'" : '';
$rs = safe_rows('event, name, data', 'txp_lang', "lang='".doSlash($lng)."'".$grpClause.$keyClause." ORDER BY event, name");
$out = $this->createIni($rs);
set_headers(array(
'content-type' => 'text/plain',
'content-disposition' => 'attachment; filename="'.$lng.'.ini"',
'content-description' => 'File Download',
'content-length' => count($out),
// Fix for IE6 PDF bug on servers configured to send cache headers.
'cache-control' => 'private'
));
echo implode(n, $out);
exit;
}
/**
* Ensure any new keys are represented in all languages.
*
* @param string $key Key to check
* @param string $grp Group (event) in which the key belongs
* @param string $lng Language that's been saved already
*/
protected function syncKeys($key, $grp, $lng)
{
$langObj = Txp::get('\Textpattern\L10n\Lang');
$installed = $langObj->installed();
foreach ($installed as $lang) {
if ($lang === $lng) {
continue;
}
safe_upsert(
'txp_lang',
array('event' => $grp, 'lastmod' => 'NOW()', 'owner' => TEXTPATTERN_LANG_OWNER_SITE),
array('name' => $key, 'lang' => $lang)
);
}
return;
}
/**
* Convert an array into .ini language file format.
*
* @param array $rs The record set to store
* @todo Come the revolution, put something like this in Textpack\Parser.php?
* @return [type] [description]
*/
protected function createIni($rs)
{
$res = array();
$lastGrp = '';
foreach ($rs as $row) {
if (is_array($row)) {
$grp = $row['event'];
if ($grp != $lastGrp) {
$res[] = "[$grp]";
}
$name = $row['name'];
$data = $row['data'];
$res[] = "$name = \"".$data."\"";
$lastGrp = $grp;
}
}
return $res;
}
/**
* Return a list of available lanugages in Txp.
*
* Polyfill for Txp < 4.7.3.
*
* @param int $flags Logical OR list of flags indiacting the type of list to return:
* TEXTPATTERN_LANG_ACTIVE: the active language
* TEXTPATTERN_LANG_INSTALLED: all installed languages
* TEXTPATTERN_LANG_AVAILABLE: all available languages in the file system
* @return array
*/
protected function languageList($flags = null)
{
if ($flags === null) {
$flags = TEXTPATTERN_LANG_ACTIVE | TEXTPATTERN_LANG_INSTALLED;
}
$installed_langs = Txp::get('\Textpattern\L10n\Lang')->available((int)$flags);
$vals = array();
foreach ($installed_langs as $lang => $langdata) {
$vals[$lang] = $langdata['name'];
if (trim($vals[$lang]) == '') {
$vals[$lang] = $lang;
}
}
ksort($vals);
reset($vals);
return $vals;
}
}