From 513aa33e6da0a1a0b89b54f8304805d9609fc164 Mon Sep 17 00:00:00 2001 From: Hypolite Petovan Date: Sat, 9 Oct 2021 10:18:53 -0400 Subject: [PATCH 1/2] [Composer] Add new geekwright/po dependency --- composer.json | 7 ++++--- composer.lock | 50 +++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/composer.json b/composer.json index 0efba36645..b7f1fc60ad 100644 --- a/composer.json +++ b/composer.json @@ -31,12 +31,15 @@ "divineomega/password_exposed": "^2.8", "ezyang/htmlpurifier": "^4.7", "friendica/json-ld": "^1.0", + "geekwright/po": "^2.0", "guzzlehttp/guzzle": "^6.5", "league/html-to-markdown": "^4.8", "level-2/dice": "^4", "lightopenid/lightopenid": "dev-master", "matriphe/iso-639": "^1.2", + "mattwright/urlresolver": "^2.0", "michelf/php-markdown": "^1.7", + "minishlink/web-push": "^6.0", "mobiledetect/mobiledetectlib": "^2.8", "monolog/monolog": "^1.25", "nikic/fast-route": "^1.3", @@ -68,9 +71,7 @@ "npm-asset/moment": "^2.24", "npm-asset/perfect-scrollbar": "0.6.16", "npm-asset/textcomplete": "^0.18.2", - "npm-asset/typeahead.js": "^0.11.1", - "minishlink/web-push": "^6.0", - "mattwright/urlresolver": "^2.0" + "npm-asset/typeahead.js": "^0.11.1" }, "repositories": [ { diff --git a/composer.lock b/composer.lock index 906a681e45..edbc73926b 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "c9e0a9eacc23d884012042eeab01cc8b", + "content-hash": "c43b7d45ba7fe0a870b75b2e4334f9da", "packages": [ { "name": "asika/simple-console", @@ -739,6 +739,54 @@ ], "time": "2019-08-08T18:36:07+00:00" }, + { + "name": "geekwright/po", + "version": "v2.0.2", + "source": { + "type": "git", + "url": "https://github.com/geekwright/Po.git", + "reference": "f9222a901d38f2101d22f767099926d945c78db5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/geekwright/Po/zipball/f9222a901d38f2101d22f767099926d945c78db5", + "reference": "f9222a901d38f2101d22f767099926d945c78db5", + "shasum": "" + }, + "require": { + "php": ">7.1" + }, + "require-dev": { + "phpunit/phpunit": "^7.0|^8.0", + "smarty/smarty": "^3.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "Geekwright\\Po\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "GPL-2.0-or-later" + ], + "authors": [ + { + "name": "Richard Griffith", + "email": "richard@geekwright.com" + } + ], + "description": "Objects to assist in reading, manipulating and creating GNU gettext style PO files", + "homepage": "https://github.com/geekwright/Po", + "keywords": [ + "gettext", + "i18n", + "l10n", + "po", + "pot" + ], + "time": "2020-12-30T23:34:48+00:00" + }, { "name": "guzzlehttp/guzzle", "version": "6.5.5", From ca2d0178fc4908d132dba2a57e4e37d93031aedb Mon Sep 17 00:00:00 2001 From: Hypolite Petovan Date: Sat, 9 Oct 2021 10:42:25 -0400 Subject: [PATCH 2/2] Improve po2php transformation - Use Geekwright\Po for po file parsing to catch corner case with multi-line message - Switch from risky double quoted strings to safer single quoted strings in the output file --- src/Console/PoToPhp.php | 305 +++++++++++++++++----------------------- 1 file changed, 128 insertions(+), 177 deletions(-) diff --git a/src/Console/PoToPhp.php b/src/Console/PoToPhp.php index 35d87165c1..3879945f84 100644 --- a/src/Console/PoToPhp.php +++ b/src/Console/PoToPhp.php @@ -21,6 +21,9 @@ namespace Friendica\Console; +use Geekwright\Po\PoFile; +use Geekwright\Po\PoTokens; + /** * Read a messages.po file and create strings.php in the same directory */ @@ -84,121 +87,8 @@ HELP; $this->out('Out to ' . $outfile); - $out = "poFile2Php($lang, $pofile); - $infile = file($pofile); - $k = ''; - $v = ''; - $arr = false; - $ink = false; - $inv = false; - $escape_s_exp = '|[^\\\\]\$[a-z]|'; - - foreach ($infile as $l) { - $l = str_replace('\"', self::DQ_ESCAPE, $l); - $len = strlen($l); - if ($l[0] == "#") { - $l = ""; - } - - if (substr($l, 0, 15) == '"Plural-Forms: ') { - $match = []; - preg_match("|nplurals=([0-9]*); *plural=(.*?)[;\\\\]|", $l, $match); - $return = $this->convertCPluralConditionToPhpReturnStatement($match[2]); - // define plural select function if not already defined - $fnname = 'string_plural_select_' . $lang; - $out .= 'if(! function_exists("' . $fnname . '")) {' . "\n"; - $out .= 'function ' . $fnname . '($n){' . "\n"; - $out .= ' $n = intval($n);' . "\n"; - $out .= ' ' . $return . "\n"; - $out .= '}}' . "\n"; - } - - if ($k != '' && substr($l, 0, 7) == 'msgstr ') { - $v = substr($l, 8, $len - 10); - $v = preg_replace_callback($escape_s_exp, [$this, 'escapeDollar'], $v); - - if ($v != '') { - $out .= '$a->strings["' . $k . '"] = "' . $v . '"'; - } else { - $k = ''; - $ink = false; - } - } - - if ($k != "" && substr($l, 0, 7) == 'msgstr[') { - if ($ink) { - $ink = false; - $out .= '$a->strings["' . $k . '"] = '; - } - if ($inv) { - $inv = false; - $out .= '"' . $v . '"'; - } - - if (!$arr) { - $arr = true; - $out .= "[\n"; - } - - $match = []; - preg_match("|\[([0-9]*)\] (.*)|", $l, $match); - if ($match[2] !== '""') { - $out .= "\t" - . preg_replace_callback($escape_s_exp, [$this, 'escapeDollar'], $match[1]) - . ' => ' - . preg_replace_callback($escape_s_exp, [$this, 'escapeDollar'], $match[2]) - . ",\n"; - } - } - - if (substr($l, 0, 6) == 'msgid_') { - $ink = false; - $out .= '$a->strings["' . $k . '"] = '; - } - - if ($ink) { - $k .= trim($l, "\"\r\n"); - $k = preg_replace_callback($escape_s_exp, [$this, 'escapeDollar'], $k); - } - - if (substr($l, 0, 6) == 'msgid ') { - if ($inv) { - $inv = false; - $out .= '"' . $v . '"'; - } - - if ($k != "") { - $out .= ($arr) ? "];\n" : ";\n"; - } - - $arr = false; - $k = str_replace("msgid ", "", $l); - if ($k != '""') { - $k = trim($k, "\"\r\n"); - } else { - $k = ''; - } - - $k = preg_replace_callback($escape_s_exp, [$this, 'escapeDollar'], $k); - $ink = true; - } - - if ($inv && substr($l, 0, 6) != "msgstr") { - $v .= trim($l, "\"\r\n"); - $v = preg_replace_callback($escape_s_exp, [$this, 'escapeDollar'], $v); - } - } - - if ($inv) { - $out .= '"' . $v . '"'; - } - - if ($k != '') { - $out .= ($arr ? "];\n" : ";\n"); - } - - $out = str_replace(self::DQ_ESCAPE, '\"', $out); if (!file_put_contents($outfile, $out)) { throw new \RuntimeException('Unable to write to ' . $outfile); } @@ -206,9 +96,70 @@ HELP; return 0; } - private function escapeDollar($match) + private function poFile2Php($lang, $infile): string { - return str_replace('$', '\$', $match[0]); + $poFile = new PoFile(); + $poFile->readPoFile($infile); + + $out = "getHeaderEntry()->getHeader('plural-forms'); + + if (!$pluralForms) { + throw new \RuntimeException('No Plural-Forms header detected'); + } + + $regex = 'nplurals=([0-9]*); *plural=(.*?)[\\\\;]'; + + if (!preg_match('|' . $regex . '|', $pluralForms, $match)) { + throw new \RuntimeException('Unexpected Plural-Forms header value, expected "' . $regex . '", found ' . $pluralForms); + } + + $out .= $this->createPluralSelectFunctionString($match[2], $lang); + + foreach ($poFile->getEntries() as $entry) { + if (!implode('', $entry->getAsStringArray(PoTokens::TRANSLATED))) { + // Skip completely untranslated entries + continue; + } + + $out .= '$a->strings[' . self::escapePhpString($entry->getAsString(PoTokens::MESSAGE)) . '] = '; + + $msgid_plural = $entry->get(PoTokens::PLURAL); + if (empty($msgid_plural)) { + $out .= self::escapePhpString($entry->getAsString(PoTokens::TRANSLATED)) . ';' . "\n"; + } else { + $out .= '[' . "\n"; + foreach($entry->getAsStringArray(PoTokens::TRANSLATED) as $key => $msgstr) { + $out .= "\t" . $key . ' => ' . self::escapePhpString($msgstr) . ',' . "\n"; + }; + + $out .= '];' . "\n"; + } + } + + return $out; + } + + private function createPluralSelectFunctionString(string $pluralForms, string $lang): string + { + $return = $this->convertCPluralConditionToPhpReturnStatement( + $pluralForms + ); + + $fnname = 'string_plural_select_' . $lang; + $out = 'if(! function_exists("' . $fnname . '")) {' . "\n"; + $out .= 'function ' . $fnname . '($n){' . "\n"; + $out .= ' $n = intval($n);' . "\n"; + $out .= ' ' . $return . "\n"; + $out .= '}}' . "\n"; + + return $out; + } + + private static function escapePhpString($string): string + { + return "'" . strtr($string, ['\'' => '\\\'']) . "'"; } /** @@ -224,68 +175,68 @@ HELP; { $cond = str_replace('n', '$n', $cond); - /** - * Parses the condition into an array if there's at least a ternary operator, to a string otherwise - * - * Warning: Black recursive magic - * - * @param string $string - * @param array|string $node - */ - function parse(string $string, &$node = []) - { - // Removes extra outward parentheses - if (strpos($string, '(') === 0 && strrpos($string, ')') === strlen($string) - 1) { - $string = substr($string, 1, -1); - } - - $q = strpos($string, '?'); - $s = strpos($string, ':'); - - if ($q === false && $s === false) { - $node = $string; - return; - } - - if ($q === false || $s < $q) { - list($then, $else) = explode(':', $string, 2); - $node['then'] = $then; - $parsedElse = []; - parse($else, $parsedElse); - $node['else'] = $parsedElse; - } else { - list($if, $thenelse) = explode('?', $string, 2); - $node['if'] = $if; - parse($thenelse, $node); - } - } - - /** - * Renders the parsed condition tree into a return statement - * - * Warning: Black recursive magic - * - * @param $tree - * @return string - */ - function render($tree) - { - if (is_array($tree)) { - $if = trim($tree['if']); - $then = trim($tree['then']); - $else = render($tree['else']); - - return "if ({$if}) { return {$then}; } else {$else}"; - } - - $tree = trim($tree); - - return " { return {$tree}; }"; - } - $tree = []; - parse($cond, $tree); + self::parse($cond, $tree); - return is_string($tree) ? "return intval({$tree});" : render($tree); + return is_string($tree) ? "return intval({$tree});" : self::render($tree); + } + + /** + * Parses the condition into an array if there's at least a ternary operator, to a string otherwise + * + * Warning: Black recursive magic + * + * @param string $string + * @param array|string $node + */ + private static function parse(string $string, &$node = []) + { + // Removes extra outward parentheses + if (strpos($string, '(') === 0 && strrpos($string, ')') === strlen($string) - 1) { + $string = substr($string, 1, -1); + } + + $q = strpos($string, '?'); + $s = strpos($string, ':'); + + if ($q === false && $s === false) { + $node = $string; + return; + } + + if ($q === false || $s < $q) { + list($then, $else) = explode(':', $string, 2); + $node['then'] = $then; + $parsedElse = []; + self::parse($else, $parsedElse); + $node['else'] = $parsedElse; + } else { + list($if, $thenelse) = explode('?', $string, 2); + $node['if'] = $if; + self::parse($thenelse, $node); + } + } + + /** + * Renders the parsed condition tree into a return statement + * + * Warning: Black recursive magic + * + * @param $tree + * @return string + */ + private static function render($tree): string + { + if (is_array($tree)) { + $if = trim($tree['if']); + $then = trim($tree['then']); + $else = self::render($tree['else']); + + return "if ({$if}) { return {$then}; } else {$else}"; + } + + $tree = trim($tree); + + return " { return {$tree}; }"; } }