Introduce class Catalog

refs #13012
This commit is contained in:
Jennifer Mourek 2016-11-25 14:05:49 +01:00
parent ab8793c69a
commit af73da2437
6 changed files with 1193 additions and 0 deletions

View File

@ -0,0 +1,269 @@
<?php
/* Icinga Web 2 | (c) 2016 Icinga Development Team | GPLv2+ */
namespace Icinga\Module\Translation\Catalog;
use ArrayIterator;
use DateTime;
use Exception;
use IteratorAggregate;
use Icinga\Application\Benchmark;
use Icinga\Exception\IcingaException;
use Icinga\Web\FileCache;
use Icinga\Module\Translation\Exception\CatalogEntryException;
use Icinga\Module\Translation\Exception\CatalogException;
use Icinga\Module\Translation\Exception\CatalogHeaderException;
/**
* Class Catalog
*
* Provides a convenient interface to handle gettext PO files.
*
* @package Icinga\Module\Translation\Catalog
*/
class Catalog implements IteratorAggregate
{
/**
* Header for this Catalog
*
* @var CatalogHeader
*/
protected $header;
/**
* Entries for this Catalog
*
* @var array
*/
protected $entries;
/**
* Create a new Catalog
*
* @param CatalogHeader $header
* @param array $entries
*/
public function __construct(CatalogHeader $header, array $entries)
{
$this->header = $header;
$this->entries = $entries;
}
/**
* Create and return a new Catalog from the given array of entries
*
* @param array $rawEntries
*
* @return Catalog
*
* @throws CatalogException
*/
public static function fromArray(array $rawEntries)
{
Benchmark::measure('Catalog::fromArray()');
$header = null;
$entries = array();
foreach ($rawEntries as $key => $rawEntry) {
if (isset($rawEntry['msgid']) && empty($rawEntry['msgid'])) {
$header = CatalogHeader::fromString($rawEntry['msgstr'][0]);
if (isset($rawEntry['translator_comments'])) {
$header->setCopyrightInformation($rawEntry['translator_comments']);
}
} else {
try {
$entries[] = CatalogEntry::fromArray($rawEntry);
} catch (CatalogEntryException $e) {
throw $e->setEntryNumber($header ? $key : $key + 1);
}
}
}
if ($header === null) {
throw new CatalogHeaderException('Header not found');
}
return new Catalog($header, $entries);
}
/**
* Create and return a new Catalog from the given path
*
* @param string $catalogPath
* @param bool $cache Whether to utilize the cache or not
*
* @return Catalog
*
* @throws CatalogException
*/
public static function fromPath($catalogPath, $cache = false)
{
Benchmark::measure('Catalog::fromPath()');
if ($cache) {
$catalog = static::fromCache($catalogPath);
if ($catalog !== null) {
return $catalog;
}
}
try {
$entries = CatalogParser::parsePath($catalogPath);
$catalog = Catalog::fromArray($entries);
} catch (CatalogHeaderException $e) {
throw new CatalogException(
'An exception occurred while reading "' . $catalogPath . '": ' . $e->getMessage()
);
} catch (CatalogEntryException $e) {
throw new CatalogException(
'Invalid entry #' . $e->getEntryNumber() . ' in "' . $catalogPath . '": ' . $e->getMessage()
);
}
if ($cache) {
static::cacheEntries($catalogPath, $entries);
}
return $catalog;
}
/**
* Attempt to create and return a new Catalog from the given cached path
*
* @param string $catalogPath
*
* @return Catalog|null Null in case the path has not been cached yet
*/
protected static function fromCache($catalogPath)
{
Benchmark::measure('Catalog::fromCache()');
$eTag = FileCache::etagForFiles($catalogPath);
$cacheFile = 'translation-' . $eTag . '.po.json';
$cache = FileCache::instance();
if ($cache->has($cacheFile)) {
return static::fromArray(json_decode($cache->get($cacheFile), true));
}
}
/**
* Cache the given catalog entries
*
* @param string $catalogPath The path to the po file which has initially been parsed
* @param array $entries The entries as produced by CatalogParser::parsePath()
*/
protected static function cacheEntries($catalogPath, array $entries)
{
$eTag = FileCache::etagForFiles($catalogPath);
$cacheFile = 'translation-' . $eTag . '.po.json';
FileCache::instance()->store($cacheFile, json_encode($entries));
}
/**
* Create and return a iterator for this catalogs entries
*
* @return ArrayIterator
*/
public function getIterator()
{
return new ArrayIterator($this->entries);
}
/**
* Return whether the given header exists
*
* @param string $name The name of the header
*
* @return bool
*/
public function hasHeader($name)
{
return isset($this->header[$name]);
}
/**
* Return the value of the given header
*
* @param string $name The name of the header
*
* @return string
*/
public function getHeader($name)
{
return $this->header[$name];
}
/**
* Set the given header to the given value
*
* @param string $name The name of the header
* @param string $value The value of the header
*
* @return $this
*/
public function setHeader($name, $value)
{
$this->header[$name] = $value;
return $this;
}
/**
* Remove the given header
*
* @param string $name The name of the header
*
* @return $this
*/
public function removeHeader($name)
{
unset ($this->header[$name]);
return $this;
}
/**
* Return the creation date of this Catalog
*
* @return DateTime
*/
public function creationDate()
{
return date_create_from_format(CatalogHeader::DATETIME_FORMAT, $this->getHeader('POT-Creation-Date'));
}
/**
* Return the revision date of this Catalog
*
* @return DateTime
*/
public function revisionDate()
{
return date_create_from_format(CatalogHeader::DATETIME_FORMAT, $this->getHeader('PO-Revision-Date'));
}
/**
* Render and return this catalog as a string
*
* @return string
*/
public function render()
{
$renderedCatalog = $this->header->render();
foreach ($this->entries as $entry) {
$renderedCatalog .= "\n\n" . $entry->render();
}
return $renderedCatalog;
}
/**
* @see Catalog::render()
*/
public function __toString()
{
try {
return $this->render();
} catch (Exception $e) {
return 'Failed to render Catalog: ' . IcingaException::describe($e);
}
}
}

View File

@ -0,0 +1,654 @@
<?php
/* Icinga Web 2 | (c) 2016 Icinga Development Team | GPLv2+ */
namespace Icinga\Module\Translation\Catalog;
use Exception;
use Icinga\Exception\IcingaException;
use Icinga\Module\Translation\Exception\CatalogEntryException;
/**
* Class CatalogEntry
*
* Provides a convenient interface to handle entries of gettext PO files.
*
* @package Icinga\Module\Translation\Catalog
*/
class CatalogEntry
{
/**
* Maximal amount of chars per line
*
* @var int
*/
const MAX_LINE_LENGTH = 80;
/**
* Regex that matches php-format placeholders
*
* @var string
*/
const PHP_FORMAT_REGEX = '/(?<!%)%(?:\d+\$)?[+-]?(?:[ 0]|\'.)?-?\d*(?:\.\d+)?[bcdeEufFgGosxX]/';
/**
* Obsolete tag for this CatalogEntry
*
* @var bool
*/
protected $obsolete;
/**
* Context for this CatalogEntry
*
* @var string
*/
protected $messageContext;
/**
* Untranslated message for this CatalogEntry
*
* @var string
*/
protected $messageId;
/**
* Untranslated plural messages for this CatalogEntry
*
* @var array
*/
protected $messageIdPlural;
/**
* Translated message for this CatalogEntry
*
* @var string
*/
protected $message;
/**
* Translated plural messages for this CatalogEntry
*
* @var array
*/
protected $messagePlurals;
/**
* Context of the message before the last change for this CatalogEntry
*
* @var string
*/
protected $previousMessageContext;
/**
* Untranslated message before the last change for this CatalogEntry
*
* @var string
*/
protected $previousMessageId;
/**
* Untranslated plural messages before the last change for this CatalogEntry
*
* @var array
*/
protected $previousMessageIdPlural;
/**
* Translator comments for this CatalogEntry
*
* @var array
*/
protected $translatorComments;
/**
* Extracted comments for this CatalogEntry
*
* @var array
*/
protected $extractedComments;
/**
* Paths in which this CatalogEntry is used
*
* @var array
*/
protected $paths;
/**
* Information about the translation status and format of this CatalogEntry
*
* @var array
*/
protected $flags;
/**
* Create a new CatalogEntry
*
* @param string $messageId The untranslated message
* @param string $message The translated message
* @param string $messageContext The message's context
*/
public function __construct($messageId, $message, $messageContext = null)
{
$this->messageId = $messageId;
$this->message = $message;
$this->messageContext = $messageContext;
}
/**
* Create and return a new CatalogEntry from the given array
*
* @param array $entry
*
* @return CatalogEntry
*
* @throws CatalogEntryException
*/
public static function fromArray(array $entry)
{
if (! isset($entry['msgid']) || ! isset($entry['msgstr'][0])) {
throw new CatalogEntryException('Missing msgid or msgstr');
}
$catalogEntry = new static(
$entry['msgid'],
$entry['msgstr'][0],
isset($entry['msgctxt']) ? $entry['msgctxt'] : null
);
foreach ($entry as $key => $value)
{
switch ($key)
{
case 'obsolete':
$catalogEntry->setObsolete($value);
break;
case 'msgid_plural':
$catalogEntry->setMessageIdPlural($value);
break;
case 'msgstr':
unset($value[0]);
if (! empty($value)) {
$catalogEntry->setMessagePlurals($value);
}
break;
case 'previous_msgctxt':
$catalogEntry->setPreviousMessageContext($value);
break;
case 'previous_msgid':
$catalogEntry->setPreviousMessageId($value);
break;
case 'previous_msgid_plural':
$catalogEntry->setPreviousMessageIdPlural($value);
break;
case 'translator_comments':
$catalogEntry->setTranslatorComments($value);
break;
case 'extracted_comments':
$catalogEntry->setExtractedComments($value);
break;
case 'paths':
$catalogEntry->setPaths($value);
break;
case 'flags':
$catalogEntry->setFlags($value);
break;
}
}
return $catalogEntry;
}
/**
* Set the message for this CatalogEntry
*
* @param string $message
*
* @return $this
*/
public function setMessage($message)
{
$this->message = $message;
return $this;
}
/**
* Return the message for this CatalogEntry
*
* @return string
*/
public function getMessage()
{
return $this->message;
}
/**
* Set the message id for this CatalogEntry
*
* @param string $messageId
*
* @return $this
*/
public function setMessageId($messageId)
{
$this->messageId = $messageId;
return $this;
}
/**
* Return the message id for this CatalogEntry
*
* @return string
*/
public function getMessageId()
{
return $this->messageId;
}
/**
* Set the message context for this CatalogEntry
*
* @param string $messageContext
*
* @return $this
*/
public function setMessageContext($messageContext)
{
$this->messageContext = $messageContext;
return $this;
}
/**
* Return the message context for this CatalogEntry
*
* @return string
*/
public function getMessageContext()
{
return $this->messageContext;
}
/**
* Set whether this CatalogEntry is obsolete
*
* @param bool $state
*
* @return $this
*/
public function setObsolete($state = true)
{
$this->obsolete = $state;
return $this;
}
/**
* Return whether this CatalogEntry is obsolete
*
* @return bool
*/
public function getObsolete()
{
return $this->obsolete;
}
/**
* Set the plural message id for this CatalogEntry
*
* @param string $messageIdPlural
*
* @return $this
*/
public function setMessageIdPlural($messageIdPlural)
{
$this->messageIdPlural = $messageIdPlural;
return $this;
}
/**
* Return the plural message id for this CatalogEntry
*
* @return string
*/
public function getMessageIdPlural()
{
return $this->messageIdPlural;
}
/**
* Set the plural messages for this CatalogEntry
*
* @param array $messagePlurals
*
* @return $this
*/
public function setMessagePlurals(array $messagePlurals)
{
$this->messagePlurals = $messagePlurals;
return $this;
}
/**
* Return the plural messages for this CatalogEntry
*
* @return array
*/
public function getMessagePlurals()
{
return $this->messagePlurals;
}
/**
* Set the previous message context for this CatalogEntry
*
* @param string $previousMessageContext
*
* @return $this
*/
public function setPreviousMessageContext($previousMessageContext)
{
$this->previousMessageContext = $previousMessageContext;
return $this;
}
/**
* Return the previous message context for this CatalogEntry
*
* @return string
*/
public function getPreviousMessageContext()
{
return $this->previousMessageContext;
}
/**
* Set the previous message id for this CatalogEntry
*
* @param string $previousMessageId
*
* @return $this
*/
public function setPreviousMessageId($previousMessageId)
{
$this->previousMessageId = $previousMessageId;
return $this;
}
/**
* Return the previous message id for this CatalogEntry
*
* @return string
*/
public function getPreviousMessageId()
{
return $this->previousMessageId;
}
/**
* Set the previous plural message id for this CatalogEntry
*
* @param string $previousMessageIdPlural
*
* @return $this
*/
public function setPreviousMessageIdPlural($previousMessageIdPlural)
{
$this->previousMessageIdPlural = $previousMessageIdPlural;
return $this;
}
/**
* Return the previous plural message id for this CatalogEntry
*
* @return string
*/
public function getPreviousMessageIdPlural()
{
return $this->previousMessageIdPlural;
}
/**
* Set translator comments for this CatalogEntry
*
* @param array $translatorComments
*
* @return $this
*/
public function setTranslatorComments(array $translatorComments)
{
$this->translatorComments = $translatorComments;
return $this;
}
/**
* Return translator comments for this CatalogEntry
*
* @return array
*/
public function getTranslatorComments()
{
return $this->translatorComments;
}
/**
* Set extracted comments for this CatalogEntry
*
* @param array $extractedComments
*
* @return $this
*/
public function setExtractedComments(array $extractedComments)
{
$this->extractedComments = $extractedComments;
return $this;
}
/**
* Return extracted comments for this CatalogEntry
*
* @return array
*/
public function getExtractedComments()
{
return $this->extractedComments;
}
/**
* Set paths for this CatalogEntry
*
* @param array $paths
*
* @return $this
*/
public function setPaths(array $paths)
{
$this->paths = $paths;
return $this;
}
/**
* Return paths for this CatalogEntry
*
* @return array
*/
public function getPaths()
{
return $this->paths;
}
/**
* Set flags for this CatalogEntry
*
* @param array $flags
*
* @return $this
*/
public function setFlags(array $flags)
{
$this->flags = $flags;
return $this;
}
/**
* Return flags for this CatalogEntry
*
* @return array
*/
public function getFlags()
{
return $this->flags;
}
/**
* Return whether this CatalogEntry is translated
*
* @return bool
*/
public function isTranslated()
{
return ! empty($this->message);
}
/**
* Return whether this CatalogEntry is fuzzy
*
* @return bool
*/
public function isFuzzy()
{
return ! empty($this->flags) && in_array('fuzzy', $this->flags);
}
/**
* Return whether this CatalogEntry is faulty
*
* @return bool
*/
public function isFaulty()
{
if ($this->getMessage()) {
$numberOfPlaceholdersInId = preg_match_all(static::PHP_FORMAT_REGEX, $this->messageId, $_);
$numberOfPlaceholdersInMessage = preg_match_all(static::PHP_FORMAT_REGEX, $this->message, $_);
if ($numberOfPlaceholdersInId !== $numberOfPlaceholdersInMessage) {
return true;
}
if ($this->getMessageIdPlural()) {
$numberOfPlaceholdersInIdPlural = preg_match_all(static::PHP_FORMAT_REGEX, $this->messageIdPlural, $_);
foreach ($this->messagePlurals as $value) {
$numberOfPlaceholdersInMessagePlural = preg_match_all(static::PHP_FORMAT_REGEX, $value, $_);
if ($numberOfPlaceholdersInIdPlural !== $numberOfPlaceholdersInMessagePlural) {
return true;
}
}
}
}
return false;
}
/**
* Return whether this CatalogEntry is obsolete
*
* @return bool
*/
public function isObsolete()
{
return $this->obsolete;
}
/**
* Render and return this CatalogEntry as string
*
* @return string
*/
public function render()
{
$entries = array_merge(
array_map(function ($value) { return '# ' . $value; }, $this->translatorComments ?: array()),
array_map(function ($value) { return '#. ' . $value; }, $this->extractedComments ?: array()),
array_map(function ($value) { return '#: ' . $value; }, $this->paths ?: array()),
array_map(function ($value) { return '#, ' . $value; }, $this->flags ?: array()),
$this->renderAttribute('#| msgctxt ', $this->previousMessageContext, '#| '),
$this->renderAttribute('#| msgid ', $this->previousMessageId, '#| '),
$this->renderAttribute('#| msgid_plural ', $this->previousMessageIdPlural, '#| '),
$this->renderAttribute('msgctxt ', $this->messageContext),
$this->renderAttribute('msgid ', $this->messageId),
$this->renderAttribute('msgid_plural ', $this->messageIdPlural)
);
if (! empty($this->messagePlurals)) {
$entries = array_merge($entries, $this->renderAttribute('msgstr[0] ', $this->message));
foreach ($this->messagePlurals as $key => $value)
{
$entries = array_merge($entries, $this->renderAttribute('msgstr[' . $key . '] ', $value));
}
} else {
$entries = array_merge($entries, $this->renderAttribute('msgstr ', $this->message));
}
return implode("\n", $entries);
}
/**
* Reformat the given string to fit line length limitation and .po format and add quotes
*
* @param string $attributePrefix The attribute prefix that will be placed in front of the string
* @param string $string The string to split
* @param string $lineContinuationPrefix The prefix that will be placed in front of the multi lines of the string
*
* @return array Returns an array of split lines
*/
public function renderAttribute($attributePrefix, $string, $lineContinuationPrefix = '')
{
if (empty($string)) {
return array();
}
$string = strtr($string, array_flip(CatalogParser::$escapedChars));
$attributePrefix = ($this->isObsolete() ? ('#~ ' . $attributePrefix) : $attributePrefix);
$oneLine = $attributePrefix . '"' . $string . '"';
if (strlen($oneLine) <= static::MAX_LINE_LENGTH) {
return array($oneLine);
}
$splitLines = array($attributePrefix . '""');
$lastSpace = 0;
$lastCut = 0;
$additionalChars = strlen($lineContinuationPrefix) + 2;
for ($i = 0; $i < strlen($string); $i++)
{
if ($i - $lastCut + $additionalChars === static::MAX_LINE_LENGTH) {
$splitLines[] = ($this->isObsolete() ? '#~ ' : '')
. $lineContinuationPrefix
. '"'
. substr($string, $lastCut, $lastSpace - $lastCut + 1)
. '"';
$lastCut = $lastSpace + 1;
}
if ($string[$i] === ' ') {
$lastSpace = $i;
}
}
$splitLines[] = ($this->isObsolete() ? '#~ ' : '')
. $lineContinuationPrefix
. '"'
. substr($string, $lastCut)
. '"';
return $splitLines;
}
/**
* @see CatalogEntry::render()
*/
public function __toString()
{
try {
return $this->render();
} catch (Exception $e) {
return 'Failed to render CatalogEntry: ' . IcingaException::describe($e);
}
}
}

View File

@ -0,0 +1,192 @@
<?php
/* Icinga Web 2 | (c) 2016 Icinga Development Team | GPLv2+ */
namespace Icinga\Module\Translation\Catalog;
use ArrayAccess;
use Exception;
use Icinga\Exception\IcingaException;
use Icinga\Module\Translation\Exception\CatalogHeaderException;
/**
* Class CatalogHeader
*
* Provides a convenient interface to handle headers of gettext PO files.
*
* @package Icinga\Module\Translation\Catalog
*/
class CatalogHeader implements ArrayAccess
{
/**
* The format used in header entries to represent date and time
*
* @var string
*/
const DATETIME_FORMAT = 'Y-m-d H:iO';
/**
* The entries of this CatalogHeader
*
* @var array
*/
protected $headers;
/**
* The copyright information for this CatalogHeader
*
* @var array
*/
protected $copyrightInformation;
/**
* Create a new CatalogHeader
*
* @param array $headers
*/
public function __construct(array $headers)
{
$this->headers = $headers;
}
/**
* Create and return a new CatalogHeader from the given string
*
* @param string $header
*
* @return CatalogHeader
*
* @throws CatalogHeaderException
*/
public static function fromString($header)
{
$lines = preg_split(
'/\n(?=\S+: )/',
substr($header, -1) === "\n"
? substr($header, 0, -1)
: $header
);
$headers = array();
foreach ($lines as $line)
{
try {
list($key, $value) = explode(': ', $line, 2);
} catch (Exception $_) {
throw new CatalogHeaderException('Missing ": " in "' . $line . '"');
}
$headers[$key] = $value;
}
return new CatalogHeader($headers);
}
/**
* Set copyright information for this CatalogHeader
*
* @param array $copyrightInformation
*
* @return $this
*/
public function setCopyrightInformation($copyrightInformation)
{
$this->copyrightInformation = $copyrightInformation;
return $this;
}
/**
* Return copyright information for this CatalogHeader
*
* @return array
*/
public function getCopyrightInformation()
{
return $this->copyrightInformation;
}
/**
* Return whether the given header exists
*
* @param string $name The name of the header
*
* @return bool
*/
public function offsetExists($name)
{
return isset($this->headers[$name]);
}
/**
* Return the value of the given header
*
* @param string $name The name of the header
*
* @return string
*/
public function offsetGet($name)
{
return $this->headers[$name];
}
/**
* Set the given header to the given value
*
* @param string $name The name of the header
* @param string $value The value of the header
*/
public function offsetSet($name, $value)
{
$this->headers[$name] = $value;
}
/**
* Unset the given header
*
* @param string $name The name of the header
*/
public function offsetUnset($name)
{
unset($this->headers[$name]);
}
/**
* Render and return this CatalogHeader as string
*
* @return string
*
* @throws CatalogHeaderException
*/
public function render()
{
if (empty($this->headers)) {
throw new CatalogHeaderException('No headers to render');
}
$entries = array();
if (! empty($this->copyrightInformation)) {
foreach ($this->copyrightInformation as $value) {
$entries[] = '# ' . $value;
}
}
$entries[] = "msgid \"\"\nmsgstr \"\"";
foreach ($this->headers as $key => $value)
{
$entries[] = '"' . strtr(sprintf('%s: %s', $key, $value), array_flip(CatalogParser::$escapedChars)) . '\n"';
}
return implode("\n", $entries);
}
/**
* @see CatalogHeader::render()
*/
public function __toString()
{
try {
return $this->render();
} catch (Exception $e) {
return 'Failed to render CatalogHeader: ' . IcingaException::describe($e);
}
}
}

View File

@ -0,0 +1,44 @@
<?php
/* Icinga Web 2 | (c) 2016 Icinga Development Team | GPLv2+ */
namespace Icinga\Module\Translation\Exception;
/**
* Class CatalogEntryException
*
* Will be thrown if catalog entry related errors are encountered.
*
* @package Icinga\Module\Translation\Exception
*/
class CatalogEntryException extends CatalogException
{
/**
* Number of the faulty entry
*
* @var int
*/
protected $entryNumber;
/**
* Set the number of the faulty entry
*
* @param int $entryNumber
*
* @return $this
*/
public function setEntryNumber($entryNumber)
{
$this->entryNumber = $entryNumber;
return $this;
}
/**
* Return the number of the faulty entry
*
* @return int
*/
public function getEntryNumber()
{
return $this->entryNumber;
}
}

View File

@ -0,0 +1,18 @@
<?php
/* Icinga Web 2 | (c) 2016 Icinga Development Team | GPLv2+ */
namespace Icinga\Module\Translation\Exception;
use Icinga\Exception\IcingaException;
/**
* Class CatalogException
*
* Will be thrown if Catalog encounters an error.
*
* @package Icinga\Module\Translation\Exception
*/
class CatalogException extends IcingaException
{
}

View File

@ -0,0 +1,16 @@
<?php
/* Icinga Web 2 | (c) 2016 Icinga Development Team | GPLv2+ */
namespace Icinga\Module\Translation\Exception;
/**
* Class CatalogHeaderException
*
* Will be thrown if catalog header related errors are encountered.
*
* @package Icinga\Module\Translation\Exception
*/
class CatalogHeaderException extends CatalogException
{
}