title = $title;
}
/**
* Constructs a translatable page from given text.
* Some functions will fail unless you set revision
* parameter manually.
*
* @param Title $title
* @param string $text
*
* @return self
*/
public static function newFromText( Title $title, $text ) {
$obj = new self( $title );
$obj->text = $text;
$obj->source = 'text';
return $obj;
}
/**
* Constructs a translatable page from given revision.
* The revision must belong to the title given or unspecified
* behavior will happen.
*
* @param Title $title
* @param int $revision Revision number
* @throws MWException
* @return self
*/
public static function newFromRevision( Title $title, $revision ) {
$rev = Revision::newFromTitle( $title, $revision );
if ( $rev === null ) {
throw new MWException( 'Revision is null' );
}
$obj = new self( $title );
$obj->source = 'revision';
$obj->revision = $revision;
return $obj;
}
/**
* Constructs a translatable page from title.
* The text of last marked revision is loaded when neded.
*
* @param Title $title
* @return self
*/
public static function newFromTitle( Title $title ) {
$obj = new self( $title );
$obj->source = 'title';
return $obj;
}
/**
* Returns the title for this translatable page.
* @return Title
*/
public function getTitle() {
return $this->title;
}
/**
* Returns the text for this translatable page.
* @throws MWException
* @return string
*/
public function getText() {
if ( $this->init === false ) {
switch ( $this->source ) {
case 'text':
break;
/** @noinspection PhpMissingBreakStatementInspection */
case 'title':
$this->revision = $this->getMarkedTag();
case 'revision':
$rev = Revision::newFromTitle( $this->getTitle(), $this->revision );
$this->text = ContentHandler::getContentText( $rev->getContent() );
break;
}
}
if ( !is_string( $this->text ) ) {
throw new MWException( 'We have no text' );
}
$this->init = true;
return $this->text;
}
/**
* Revision is null if object was constructed using newFromText.
* @return null|int
*/
public function getRevision() {
return $this->revision;
}
/**
* Manually set a revision number to use loading page text.
* @param int $revision
*/
public function setRevision( $revision ) {
$this->revision = $revision;
$this->source = 'revision';
$this->init = false;
}
/**
* Returns the source language of this translatable page. In other words
* the language in which the page without language code is written.
* @return string
* @since 2013-01-28
*/
public function getSourceLanguageCode() {
return $this->getTitle()->getPageLanguage()->getCode();
}
/**
* Returns MessageGroup id (to be) used for translating this page.
* @return string
*/
public function getMessageGroupId() {
return self::getMessageGroupIdFromTitle( $this->getTitle() );
}
/**
* Constructs MessageGroup id for any title.
* @param Title $title
* @return string
*/
public static function getMessageGroupIdFromTitle( Title $title ) {
return 'page-' . $title->getPrefixedText();
}
/**
* Returns MessageGroup used for translating this page. It may still be empty
* if the page has not been ever marked.
* @return WikiPageMessageGroup
*/
public function getMessageGroup() {
return MessageGroups::getGroup( $this->getMessageGroupId() );
}
/**
* Check whether title is marked for translation
* @return bool
* @since 2014.06
*/
public function hasPageDisplayTitle() {
// Cached value
if ( $this->pageDisplayTitle !== null ) {
return $this->pageDisplayTitle;
}
$this->pageDisplayTitle = true;
// Check if title section exists in list of sections
$previous = $this->getSections();
if ( $previous && !in_array( $this->displayTitle, $previous ) ) {
$this->pageDisplayTitle = false;
}
return $this->pageDisplayTitle;
}
/**
* Get translated page title.
* @param string $code Language code.
* @return string|null
*/
public function getPageDisplayTitle( $code ) {
// Return null if title not marked for translation
if ( !$this->hasPageDisplayTitle() ) {
return null;
}
// Display title from DB
$section = str_replace( ' ', '_', $this->displayTitle );
$page = $this->getTitle()->getPrefixedDBkey();
$group = $this->getMessageGroup();
// Sanity check, seems to happen during moves
if ( !$group instanceof WikiPageMessageGroup ) {
return null;
}
return $group->getMessage( "$page/$section", $code, $group::READ_NORMAL );
}
/**
* Returns a TPParse object which represents the parsed page.
*
* @throws TPException
* @return TPParse
*/
public function getParse() {
if ( isset( $this->cachedParse ) ) {
return $this->cachedParse;
}
$text = $this->getText();
$nowiki = [];
$text = self::armourNowiki( $nowiki, $text );
$sections = [];
// Add section to allow translating the page name
$displaytitle = new TPSection;
$displaytitle->id = $this->displayTitle;
$displaytitle->text = $this->getTitle()->getPrefixedText();
$sections[TranslateUtils::getPlaceholder()] = $displaytitle;
$tagPlaceHolders = [];
while ( true ) {
$re = '~()(.*?)()~s';
$matches = [];
$ok = preg_match_all( $re, $text, $matches, PREG_OFFSET_CAPTURE );
if ( $ok === 0 ) {
break; // No matches
}
// Do-placehold for the whole stuff
$ph = TranslateUtils::getPlaceholder();
$start = $matches[0][0][1];
$len = strlen( $matches[0][0][0] );
$end = $start + $len;
$text = self::index_replace( $text, $ph, $start, $end );
// Sectionise the contents
// Strip the surrounding tags
$contents = $matches[0][0][0]; // full match
$start = $matches[2][0][1] - $matches[0][0][1]; // bytes before actual content
$len = strlen( $matches[2][0][0] ); // len of the content
$end = $start + $len;
$sectiontext = substr( $contents, $start, $len );
if ( strpos( $sectiontext, '' ) !== false ) {
throw new TPException( [ 'pt-parse-nested', $sectiontext ] );
}
$sectiontext = self::unArmourNowiki( $nowiki, $sectiontext );
$parse = self::sectionise( $sectiontext );
$sections += $parse['sections'];
$tagPlaceHolders[$ph] =
self::index_replace( $contents, $parse['template'], $start, $end );
}
$prettyTemplate = $text;
foreach ( $tagPlaceHolders as $ph => $value ) {
$prettyTemplate = str_replace( $ph, '[...]', $prettyTemplate );
}
if ( strpos( $text, '' ) !== false ) {
throw new TPException( [ 'pt-parse-open', $prettyTemplate ] );
} elseif ( strpos( $text, '' ) !== false ) {
throw new TPException( [ 'pt-parse-close', $prettyTemplate ] );
}
foreach ( $tagPlaceHolders as $ph => $value ) {
$text = str_replace( $ph, $value, $text );
}
if ( count( $sections ) === 1 ) {
// Don't return display title for pages which have no sections
$sections = [];
}
$text = self::unArmourNowiki( $nowiki, $text );
$parse = new TPParse( $this->getTitle() );
$parse->template = $text;
$parse->sections = $sections;
// Cache it
$this->cachedParse = $parse;
return $parse;
}
/**
* Remove all opening and closing translate tags following the same whitespace rules
* as the regular parsing. The difference is that this doesn't try to parse the page,
* so it can handle unbalanced tags.
*
* @param string $text Wikitext
* @return string Wikitext without translate tags.
*/
public static function cleanupTags( $text ) {
$nowiki = [];
$text = self::armourNowiki( $nowiki, $text );
$text = preg_replace( '~\n?~s', '', $text );
$text = preg_replace( '~\n?~s', '', $text );
// Mirroring what TPSection::getTextForTrans does
$text = preg_replace( '~]+)>(.*?)>~u', '\2', $text );
$text = self::unArmourNowiki( $nowiki, $text );
return $text;
}
/**
* @param array &$holders
* @param string $text
* @return string
*/
public static function armourNowiki( &$holders, $text ) {
$re = '~()(.*?)()~s';
while ( preg_match( $re, $text, $matches ) ) {
$ph = TranslateUtils::getPlaceholder();
$text = str_replace( $matches[0], $ph, $text );
$holders[$ph] = $matches[0];
}
return $text;
}
/**
* @param array $holders
* @param string $text
* @return mixed
*/
public static function unArmourNowiki( $holders, $text ) {
foreach ( $holders as $ph => $value ) {
$text = str_replace( $ph, $value, $text );
}
return $text;
}
/**
* @param string $string
* @param string $rep
* @param int $start
* @param int $end
* @return string
*/
protected static function index_replace( $string, $rep, $start, $end ) {
return substr( $string, 0, $start ) . $rep . substr( $string, $end );
}
/**
* Splits the content marked with \ tags into sections, which
* are separated with with two or more newlines. Extra whitespace is captured
* in the template and is not included in the sections.
*
* @param string $text Contents of one pair of \ tags.
* @return array Contains a template and array of unparsed sections.
*/
public static function sectionise( $text ) {
$flags = PREG_SPLIT_NO_EMPTY | PREG_SPLIT_DELIM_CAPTURE;
$parts = preg_split( '~(^\s*|\s*\n\n\s*|\s*$)~', $text, -1, $flags );
$inline = preg_match( '~\n~', $text ) === 0;
$template = '';
$sections = [];
foreach ( $parts as $_ ) {
if ( trim( $_ ) === '' ) {
$template .= $_;
} else {
$ph = TranslateUtils::getPlaceholder();
$tpsection = self::shakeSection( $_ );
$tpsection->setIsInline( $inline );
$sections[$ph] = $tpsection;
$template .= $ph;
}
}
return [
'template' => $template,
'sections' => $sections,
];
}
/**
* Checks if this section already contains a section marker. If there
* is not, a new one will be created. Marker will have the value of
* -1, which will later be replaced with a real value.
*
* May throw a TPException if there is error with existing section
* markers.
*
* @param string $content Content of one section
* @throws TPException
* @return TPSection
*/
public static function shakeSection( $content ) {
$re = '~~';
$matches = [];
$count = preg_match_all( $re, $content, $matches, PREG_SET_ORDER );
if ( $count > 1 ) {
throw new TPException( [ 'pt-shake-multiple', $content ] );
}
$section = new TPSection;
if ( $count === 1 ) {
foreach ( $matches as $match ) {
list( /*full*/, $id ) = $match;
$section->id = $id;
// Currently handle only these two standard places.
// Is this too strict?
$rer1 = '~^( |\n)~'; // Normal sections
$rer2 = '~\s*$~m'; // Sections with title
$content = preg_replace( $rer1, '', $content );
$content = preg_replace( $rer2, '', $content );
if ( preg_match( $re, $content ) === 1 ) {
throw new TPException( [ 'pt-shake-position', $content ] );
} elseif ( trim( $content ) === '' ) {
throw new TPException( [ 'pt-shake-empty', $id ] );
}
}
} else {
// New section
$section->id = -1;
}
$section->text = $content;
return $section;
}
protected static $tagCache = [];
/**
* Adds a tag which indicates that this page is
* suitable for translation.
* @param int $revision
* @param null|string $value
*/
public function addMarkedTag( $revision, $value = null ) {
$this->addTag( 'tp:mark', $revision, $value );
}
/**
* Adds a tag which indicates that this page source is
* ready for marking for translation.
* @param int $revision
*/
public function addReadyTag( $revision ) {
$this->addTag( 'tp:tag', $revision );
}
/**
* @param string $tag Tag name
* @param int $revision Revision ID to add tag for
* @param mixed|null $value Optional. Value to be stored as serialized with | as separator
* @throws MWException
*/
protected function addTag( $tag, $revision, $value = null ) {
$dbw = wfGetDB( DB_MASTER );
$aid = $this->getTitle()->getArticleID();
if ( is_object( $revision ) ) {
throw new MWException( 'Got object, expected id' );
}
$conds = [
'rt_page' => $aid,
'rt_type' => RevTag::getType( $tag ),
'rt_revision' => $revision
];
$dbw->delete( 'revtag', $conds, __METHOD__ );
if ( $value !== null ) {
$conds['rt_value'] = serialize( implode( '|', $value ) );
}
$dbw->insert( 'revtag', $conds, __METHOD__ );
self::$tagCache[$aid][$tag] = $revision;
}
/**
* Returns the latest revision which has marked tag, if any.
* @return int|bool false
*/
public function getMarkedTag() {
return $this->getTag( 'tp:mark' );
}
/**
* Returns the latest revision which has ready tag, if any.
* @return int|bool false
*/
public function getReadyTag() {
return $this->getTag( 'tp:tag' );
}
/**
* Removes all page translation feature data from the database.
* Does not remove translated sections or translation pages.
*/
public function unmarkTranslatablePage() {
$aid = $this->getTitle()->getArticleID();
$dbw = wfGetDB( DB_MASTER );
$conds = [
'rt_page' => $aid,
'rt_type' => [
RevTag::getType( 'tp:mark' ),
RevTag::getType( 'tp:tag' ),
],
];
$dbw->delete( 'revtag', $conds, __METHOD__ );
$dbw->delete( 'translate_sections', [ 'trs_page' => $aid ], __METHOD__ );
unset( self::$tagCache[$aid] );
}
/**
* @param string $tag
* @param int $dbt
* @return int|bool False if tag is not found, else revision id
*/
protected function getTag( $tag, $dbt = DB_REPLICA ) {
if ( !$this->getTitle()->exists() ) {
return false;
}
$aid = $this->getTitle()->getArticleID();
// ATTENTION: Cache should only be updated on POST requests.
if ( isset( self::$tagCache[$aid][$tag] ) ) {
return self::$tagCache[$aid][$tag];
}
$db = wfGetDB( $dbt );
$conds = [
'rt_page' => $aid,
'rt_type' => RevTag::getType( $tag ),
];
$options = [ 'ORDER BY' => 'rt_revision DESC' ];
$value = $db->selectField( 'revtag', 'rt_revision', $conds, __METHOD__, $options );
return $value === false ? $value : (int)$value;
}
/**
* Produces a link to translation view of a translation page.
* @param string|bool $code MediaWiki language code. Default: false.
* @return string Relative url
*/
public function getTranslationUrl( $code = false ) {
$params = [
'group' => $this->getMessageGroupId(),
'action' => 'page',
'filter' => '',
'language' => $code,
];
$translate = SpecialPage::getTitleFor( 'Translate' );
return $translate->getLocalURL( $params );
}
public function getMarkedRevs() {
$db = TranslateUtils::getSafeReadDB();
$fields = [ 'rt_revision', 'rt_value' ];
$conds = [
'rt_page' => $this->getTitle()->getArticleID(),
'rt_type' => RevTag::getType( 'tp:mark' ),
];
$options = [ 'ORDER BY' => 'rt_revision DESC' ];
return $db->select( 'revtag', $fields, $conds, __METHOD__, $options );
}
/**
* Fetch the available translation pages from database
* @return Title[]
*/
public function getTranslationPages() {
$dbr = TranslateUtils::getSafeReadDB();
$prefix = $this->getTitle()->getDBkey() . '/';
$likePattern = $dbr->buildLike( $prefix, $dbr->anyString() );
$res = $dbr->select(
'page',
[ 'page_namespace', 'page_title' ],
[
'page_namespace' => $this->getTitle()->getNamespace(),
"page_title $likePattern"
],
__METHOD__
);
$titles = TitleArray::newFromResult( $res );
$filtered = [];
// Make sure we only get translation subpages while ignoring others
$codes = Language::fetchLanguageNames();
$prefix = $this->getTitle()->getText();
/** @var Title $title */
foreach ( $titles as $title ) {
list( $name, $code ) = TranslateUtils::figureMessage( $title->getText() );
if ( !isset( $codes[$code] ) || $name !== $prefix ) {
continue;
}
$filtered[] = $title;
}
return $filtered;
}
/**
* Returns a list section ids.
* @return string[] List of string
* @since 2012-08-06
*/
protected function getSections() {
$dbr = TranslateUtils::getSafeReadDB();
$conds = [ 'trs_page' => $this->getTitle()->getArticleID() ];
$res = $dbr->select( 'translate_sections', 'trs_key', $conds, __METHOD__ );
$sections = [];
foreach ( $res as $row ) {
$sections[] = $row->trs_key;
}
return $sections;
}
/**
* Returns a list of translation unit pages.
* @param string $set Can be either 'all', or 'active'
* @param string|bool $code Only list unit pages in given language.
* @return Title[] List of Titles.
* @since 2012-08-06
*/
public function getTranslationUnitPages( $set = 'active', $code = false ) {
$dbw = wfGetDB( DB_MASTER );
$base = $this->getTitle()->getPrefixedDBkey();
// Including the / used as separator
$baseLength = strlen( $base ) + 1;
if ( $code !== false ) {
$like = $dbw->buildLike( "$base/", $dbw->anyString(), "/$code" );
} else {
$like = $dbw->buildLike( "$base/", $dbw->anyString() );
}
$fields = [ 'page_namespace', 'page_title' ];
$conds = [
'page_namespace' => NS_TRANSLATIONS,
'page_title ' . $like
];
$res = $dbw->select( 'page', $fields, $conds, __METHOD__ );
// Only include pages which belong to this translatable page.
// Problematic cases are when pages Foo and Foo/bar are both
// translatable. Then when querying for Foo, we also get units
// belonging to Foo/bar.
$sections = array_flip( $this->getSections() );
$units = [];
foreach ( $res as $row ) {
$title = Title::newFromRow( $row );
// Strip the language code and the name of the
// translatable to get plain section key
$handle = new MessageHandle( $title );
$key = substr( $handle->getKey(), $baseLength );
if ( strpos( $key, '/' ) !== false ) {
// Probably belongs to translatable subpage
continue;
}
// Check against list of sections if requested
if ( $set === 'active' && !isset( $sections[$key] ) ) {
continue;
}
// We have a match :)
$units[] = $title;
}
return $units;
}
/**
*
* @return array
*/
public function getTranslationPercentages() {
// Calculate percentages for the available translations
$group = $this->getMessageGroup();
if ( !$group instanceof WikiPageMessageGroup ) {
return [];
}
$titles = $this->getTranslationPages();
$temp = MessageGroupStats::forGroup( $this->getMessageGroupId() );
$stats = [];
foreach ( $titles as $t ) {
$handle = new MessageHandle( $t );
$code = $handle->getCode();
// Sometimes we want to display 0.00 for pages for which translation
// hasn't started yet.
$stats[$code] = 0.00;
if ( isset( $temp[$code] ) && $temp[$code][MessageGroupStats::TOTAL] > 0 ) {
$total = $temp[$code][MessageGroupStats::TOTAL];
$translated = $temp[$code][MessageGroupStats::TRANSLATED];
$percentage = $translated / $total;
$stats[$code] = sprintf( '%.2f', $percentage );
}
}
// Content language is always up-to-date
$stats[$this->getSourceLanguageCode()] = 1.00;
return $stats;
}
public function getTransRev( $suffix ) {
$title = Title::makeTitle( NS_TRANSLATIONS, $suffix );
$db = TranslateUtils::getSafeReadDB();
$fields = 'rt_value';
$conds = [
'rt_page' => $title->getArticleID(),
'rt_type' => RevTag::getType( 'tp:transver' ),
];
$options = [ 'ORDER BY' => 'rt_revision DESC' ];
return $db->selectField( 'revtag', $fields, $conds, __METHOD__, $options );
}
/**
* @param Title $title
* @return bool|self
*/
public static function isTranslationPage( Title $title ) {
$handle = new MessageHandle( $title );
$key = $handle->getKey();
$code = $handle->getCode();
if ( $key === '' || $code === '' ) {
return false;
}
$codes = Language::fetchLanguageNames();
global $wgTranslateDocumentationLanguageCode;
unset( $codes[$wgTranslateDocumentationLanguageCode] );
if ( !isset( $codes[$code] ) ) {
return false;
}
$newtitle = self::changeTitleText( $title, $key );
if ( !$newtitle ) {
return false;
}
$page = self::newFromTitle( $newtitle );
if ( $page->getMarkedTag() === false ) {
return false;
}
return $page;
}
protected static function changeTitleText( Title $title, $text ) {
return Title::makeTitleSafe( $title->getNamespace(), $text );
}
/**
* @param Title $title
* @return bool
*/
public static function isSourcePage( Title $title ) {
$cache = ObjectCache::getMainWANInstance();
$pcTTL = $cache::TTL_PROC_LONG;
$translatablePageIds = $cache->getWithSetCallback(
$cache->makeKey( 'pagetranslation', 'sourcepages' ),
$cache::TTL_MINUTE * 5,
function ( $oldValue, &$ttl, array &$setOpts ) {
$dbr = wfGetDB( DB_REPLICA );
$setOpts += Database::getCacheSetOptions( $dbr );
return self::getTranslatablePages();
},
[ 'pcTTL' => $pcTTL, 'pcGroup' => __CLASS__ . ':30' ]
);
return in_array( $title->getArticleID(), $translatablePageIds );
}
/**
* Get a list of page ids where the latest revision is either tagged or marked
* @return array
*/
public static function getTranslatablePages() {
$dbr = TranslateUtils::getSafeReadDB();
$tables = [ 'revtag', 'page' ];
$fields = 'rt_page';
$conds = [
'rt_page = page_id',
'rt_revision = page_latest',
'rt_type' => [ RevTag::getType( 'tp:mark' ), RevTag::getType( 'tp:tag' ) ],
];
$options = [ 'GROUP BY' => 'rt_page' ];
$res = $dbr->select( $tables, $fields, $conds, __METHOD__, $options );
$results = [];
foreach ( $res as $row ) {
$results[] = $row->rt_page;
}
return $results;
}
}