diff options
author | Yaco <franco@reevo.org> | 2020-06-04 11:01:00 -0300 |
---|---|---|
committer | Yaco <franco@reevo.org> | 2020-06-04 11:01:00 -0300 |
commit | fc7369835258467bf97eb64f184b93691f9a9fd5 (patch) | |
tree | daabd60089d2dd76d9f5fb416b005fbe159c799d /www/wiki/extensions/Translate/tag |
first commit
Diffstat (limited to 'www/wiki/extensions/Translate/tag')
16 files changed, 5813 insertions, 0 deletions
diff --git a/www/wiki/extensions/Translate/tag/PageTranslationHooks.php b/www/wiki/extensions/Translate/tag/PageTranslationHooks.php new file mode 100644 index 00000000..607440df --- /dev/null +++ b/www/wiki/extensions/Translate/tag/PageTranslationHooks.php @@ -0,0 +1,1327 @@ +<?php +/** + * Contains class with page translation feature hooks. + * + * @file + * @author Niklas Laxström + * @license GPL-2.0-or-later + */ + +use Wikimedia\ScopedCallback; + +/** + * Hooks for page translation. + * + * @ingroup PageTranslation + */ +class PageTranslationHooks { + // Uuugly hacks + public static $allowTargetEdit = false; + + // Check if job queue is running + public static $jobQueueRunning = false; + + // Check if we are just rendering tags or such + public static $renderingContext = false; + + // Used to communicate data between LanguageLinks and SkinTemplateGetLanguageLink hooks. + private static $languageLinkData = []; + + /** + * Hook: ParserBeforeStrip + * @param Parser $parser + * @param string &$text + * @param string $state + * @return bool + */ + public static function renderTagPage( $parser, &$text, $state ) { + $title = $parser->getTitle(); + + if ( strpos( $text, '<translate>' ) !== false ) { + try { + $parse = TranslatablePage::newFromText( $parser->getTitle(), $text )->getParse(); + $text = $parse->getTranslationPageText( null ); + $parser->getOutput()->addModuleStyles( 'ext.translate' ); + } catch ( TPException $e ) { + wfDebug( 'TPException caught; expected' ); + } + } + + // For section previews, perform additional clean-up, given tags are often + // unbalanced when we preview one section only. + if ( $parser->getOptions()->getIsSectionPreview() ) { + $text = TranslatablePage::cleanupTags( $text ); + } + + // Set display title + $page = TranslatablePage::isTranslationPage( $title ); + if ( !$page ) { + return true; + } + + self::$renderingContext = true; + list( , $code ) = TranslateUtils::figureMessage( $title->getText() ); + $name = $page->getPageDisplayTitle( $code ); + if ( $name ) { + $name = $parser->recursivePreprocess( $name ); + $parser->getOutput()->setDisplayTitle( $name ); + } + self::$renderingContext = false; + + $parser->getOutput()->setExtensionData( + 'translate-translation-page', + [ + 'sourcepagetitle' => $page->getTitle(), + 'languagecode' => $code, + 'messagegroupid' => $page->getMessageGroupId() + ] + ); + + // Disable edit section links + $parser->getOutput()->setExtensionData( 'Translate-noeditsection', true ); + if ( !defined( 'ParserOutput::SUPPORTS_STATELESS_TRANSFORMS' ) ) { + $parser->getOptions()->setEditSection( false ); + } + + return true; + } + + /** + * Hook: ParserOutputPostCacheTransform + * @param ParserOutput $out + * @param string &$text + * @param array &$options + */ + public static function onParserOutputPostCacheTransform( + ParserOutput $out, &$text, array &$options + ) { + if ( $out->getExtensionData( 'Translate-noeditsection' ) ) { + $options['enableSectionEditLinks'] = false; + } + } + + /** + * Set the right page content language for translated pages ("Page/xx"). + * Hook: PageContentLanguage + * + * @param Title $title + * @param Language|StubUserLang|string &$pageLang + * @return true + */ + public static function onPageContentLanguage( Title $title, &$pageLang ) { + // For translation pages, parse plural, grammar etc with correct language, + // and set the right direction + if ( TranslatablePage::isTranslationPage( $title ) ) { + list( , $code ) = TranslateUtils::figureMessage( $title->getText() ); + $pageLang = Language::factory( $code ); + } + + return true; + } + + /** + * Display an edit notice for translatable source pages if it's enabled + * Hook: TitleGetEditNotices + * + * @param Title $title + * @param int $oldid + * @param array &$notices + */ + public static function onTitleGetEditNotices( Title $title, $oldid, array &$notices ) { + $msg = wfMessage( 'translate-edit-tag-warning' )->inContentLanguage(); + + if ( !$msg->isDisabled() && TranslatablePage::isSourcePage( $title ) ) { + $notices['translate-tag'] = $msg->parseAsBlock(); + } + } + + /** + * Hook: OutputPageBeforeHTML + * @param OutputPage $out + * @param string $text + * @return true + */ + public static function injectCss( OutputPage $out, /*string*/ $text ) { + global $wgTranslatePageTranslationULS; + + $title = $out->getTitle(); + $isSource = TranslatablePage::isSourcePage( $title ); + $isTranslation = TranslatablePage::isTranslationPage( $title ); + + if ( $isSource || $isTranslation ) { + if ( $wgTranslatePageTranslationULS ) { + $out->addModules( 'ext.translate.pagetranslation.uls' ); + } + + if ( $isTranslation ) { + // Source pages get this module via <translate>, but for translation + // pages we need to add it manually. + $out->addModuleStyles( 'ext.translate' ); + $out->addJsConfigVars( 'wgTranslatePageTranslation', 'translation' ); + } else { + $out->addJsConfigVars( 'wgTranslatePageTranslation', 'source' ); + } + } + + return true; + } + + /** + * This is triggered after saves to translation unit pages + * @param WikiPage $wikiPage + * @param User $user + * @param TextContent $content + * @param string $summary + * @param bool $minor + * @param int $flags + * @param Revision $revision + * @param MessageHandle $handle + * @return true + */ + public static function onSectionSave( WikiPage $wikiPage, User $user, TextContent $content, + $summary, $minor, $flags, $revision, MessageHandle $handle + ) { + // FuzzyBot may do some duplicate work already worked on by other jobs + if ( FuzzyBot::getName() === $user->getName() ) { + return true; + } + + $group = $handle->getGroup(); + if ( !$group instanceof WikiPageMessageGroup ) { + return true; + } + + // Finally we know the title and can construct a Translatable page + $page = TranslatablePage::newFromTitle( $group->getTitle() ); + + // Update the target translation page + if ( !$handle->isDoc() ) { + $code = $handle->getCode(); + self::updateTranslationPage( $page, $code, $user, $flags, $summary ); + } + + return true; + } + + public static function updateTranslationPage( TranslatablePage $page, + $code, $user, $flags, $summary + ) { + $source = $page->getTitle(); + $target = $source->getSubpage( $code ); + + // We don't know and don't care + $flags &= ~EDIT_NEW & ~EDIT_UPDATE; + + // Update the target page + $job = TranslateRenderJob::newJob( $target ); + $job->setUser( $user ); + $job->setSummary( $summary ); + $job->setFlags( $flags ); + $job->run(); + + // Invalidate caches so that language bar is up-to-date + $pages = $page->getTranslationPages(); + foreach ( $pages as $title ) { + $wikiPage = WikiPage::factory( $title ); + $wikiPage->doPurge(); + } + $sourceWikiPage = WikiPage::factory( $source ); + $sourceWikiPage->doPurge(); + } + + /** + * @param string $data + * @param array $params + * @param Parser $parser + * @return string + */ + public static function languages( $data, $params, $parser ) { + global $wgPageTranslationLanguageList; + + if ( $wgPageTranslationLanguageList === 'sidebar-only' ) { + return ''; + } + + self::$renderingContext = true; + $context = new ScopedCallback( function () { + self::$renderingContext = false; + } ); + + // Add a dummy language link that is removed in self::addLanguageLinks. + if ( $wgPageTranslationLanguageList === 'sidebar-fallback' ) { + $parser->getOutput()->addLanguageLink( 'x-pagetranslation-tag' ); + } + + $currentTitle = $parser->getTitle(); + $pageStatus = self::getTranslatablePageStatus( $currentTitle ); + if ( !$pageStatus ) { + return ''; + } + + $page = $pageStatus[ 'page' ]; + $status = $pageStatus[ 'languages' ]; + $pageTitle = $page->getTitle(); + + // Sort by language code, which seems to be the only sane method + ksort( $status ); + + // This way the parser knows to fragment the parser cache by language code + $userLang = $parser->getOptions()->getUserLangObj(); + $userLangCode = $userLang->getCode(); + // Should call $page->getMessageGroup()->getSourceLanguage(), but + // group is sometimes null on WMF during page moves, reason unknown. + // This should do the same thing for now. + $sourceLanguage = $pageTitle->getPageLanguage()->getCode(); + + $languages = []; + foreach ( $status as $code => $percent ) { + // Get autonyms (null) + $name = TranslateUtils::getLanguageName( $code, null ); + $name = htmlspecialchars( $name ); // Unlikely, but better safe + + // Add links to other languages + $suffix = ( $code === $sourceLanguage ) ? '' : "/$code"; + $targetTitleString = $pageTitle->getDBkey() . $suffix; + $subpage = Title::makeTitle( $pageTitle->getNamespace(), $targetTitleString ); + + $classes = []; + if ( $code === $userLangCode ) { + $classes[] = 'mw-pt-languages-ui'; + } + + if ( $currentTitle->equals( $subpage ) ) { + $classes[] = 'mw-pt-languages-selected'; + $classes = array_merge( $classes, self::tpProgressIcon( $percent ) ); + $element = Html::rawElement( + 'span', + [ 'class' => $classes , 'lang' => LanguageCode::bcp47( $code ) ], + $name + ); + } elseif ( $subpage->isKnown() ) { + $pagename = $page->getPageDisplayTitle( $code ); + if ( !is_string( $pagename ) ) { + $pagename = $subpage->getPrefixedText(); + } + + $classes = array_merge( $classes, self::tpProgressIcon( $percent ) ); + + $title = wfMessage( 'tpt-languages-nonzero' ) + ->inLanguage( $userLang ) + ->params( $pagename ) + ->numParams( 100 * $percent ) + ->text(); + $attribs = [ + 'title' => $title, + 'class' => $classes, + 'lang' => LanguageCode::bcp47( $code ), + ]; + + $element = Linker::linkKnown( $subpage, $name, $attribs ); + } else { + /* When language is included because it is a priority language, + * but translation does not yet exists, link directly to the + * translation view. */ + $specialTranslateTitle = SpecialPage::getTitleFor( 'Translate' ); + $params = [ + 'group' => $page->getMessageGroupId(), + 'language' => $code, + 'task' => 'view' + ]; + + $classes[] = 'new'; // For red link color + $attribs = [ + 'title' => wfMessage( 'tpt-languages-zero' )->inLanguage( $userLang )->text(), + 'class' => $classes, + ]; + $element = Linker::linkKnown( $specialTranslateTitle, $name, $attribs, $params ); + } + + $languages[ $name ] = $element; + } + + // Sort languages by autonym + ksort( $languages ); + $languages = array_values( $languages ); + + // dirmark (rlm/lrm) is added, because languages with RTL names can + // mess the display + $sep = wfMessage( 'tpt-languages-separator' )->inLanguage( $userLang )->escaped(); + $sep .= $userLang->getDirMark(); + $languages = implode( $sep, $languages ); + + $out = Html::openElement( 'div', [ + 'class' => 'mw-pt-languages noprint', + 'lang' => $userLang->getHtmlCode(), + 'dir' => $userLang->getDir() + ] ); + $out .= Html::rawElement( 'div', [ 'class' => 'mw-pt-languages-label' ], + wfMessage( 'tpt-languages-legend' )->inLanguage( $userLang )->escaped() + ); + $out .= Html::rawElement( + 'div', + [ 'class' => 'mw-pt-languages-list autonym' ], + $languages + ); + $out .= Html::closeElement( 'div' ); + + $parser->getOutput()->addModuleStyles( 'ext.translate.tag.languages' ); + + return $out; + } + + /** + * Return icon CSS class for given progress status: percentages + * are too accurate and take more space than simple images. + * @param float $percent + * @return string[] + */ + protected static function tpProgressIcon( $percent ) { + $classes = [ 'mw-pt-progress' ]; + $percent *= 100; + if ( $percent < 20 ) { + $classes[] = 'mw-pt-progress--stub'; + } elseif ( $percent < 40 ) { + $classes[] = 'mw-pt-progress--low'; + } elseif ( $percent < 60 ) { + $classes[] = 'mw-pt-progress--med'; + } elseif ( $percent < 80 ) { + $classes[] = 'mw-pt-progress--high'; + } else { + $classes[] = 'mw-pt-progress--complete'; + } + return $classes; + } + + /** + * Returns translatable page and language stats for given title. + * @param Title $title + * @return array|null Returns null if not a translatable page. + */ + private static function getTranslatablePageStatus( Title $title ) { + // Check if this is a source page or a translation page + $page = TranslatablePage::newFromTitle( $title ); + if ( $page->getMarkedTag() === false ) { + $page = TranslatablePage::isTranslationPage( $title ); + } + + if ( $page === false || $page->getMarkedTag() === false ) { + return null; + } + + $status = $page->getTranslationPercentages(); + if ( !$status ) { + return null; + } + + // If priority languages have been set always show those languages + $priorityLangs = TranslateMetadata::get( $page->getMessageGroupId(), 'prioritylangs' ); + $priorityForce = TranslateMetadata::get( $page->getMessageGroupId(), 'priorityforce' ); + $filter = null; + if ( strlen( $priorityLangs ) > 0 ) { + $filter = array_flip( explode( ',', $priorityLangs ) ); + } + if ( $filter !== null ) { + // If translation is restricted to some languages, only show them + if ( $priorityForce === 'on' ) { + // Do not filter the source language link + $filter[$page->getMessageGroup()->getSourceLanguage()] = true; + $status = array_intersect_key( $status, $filter ); + } + foreach ( $filter as $langCode => $value ) { + if ( !isset( $status[$langCode] ) ) { + // We need to show all priority languages even if no translation started + $status[$langCode] = 0; + } + } + } + + return [ + 'page' => $page, + 'languages' => $status + ]; + } + + /** + * Hooks: LanguageLinks + * @param Title $title Title of the page for which links are needed. + * @param array &$languageLinks List of language links to modify. + */ + public static function addLanguageLinks( Title $title, array &$languageLinks ) { + global $wgPageTranslationLanguageList; + + $hasLanguagesTag = false; + foreach ( $languageLinks as $index => $name ) { + if ( $name === 'x-pagetranslation-tag' ) { + $hasLanguagesTag = true; + unset( $languageLinks[ $index ] ); + } + } + + if ( $wgPageTranslationLanguageList === 'tag-only' ) { + return; + } + + if ( $wgPageTranslationLanguageList === 'sidebar-fallback' && $hasLanguagesTag ) { + return; + } + + // $wgPageTranslationLanguageList === 'sidebar-always' OR 'sidebar-only' + + $status = self::getTranslatablePageStatus( $title ); + if ( !$status ) { + return; + } + + self::$renderingContext = true; + $context = new ScopedCallback( function () { + self::$renderingContext = false; + } ); + + $page = $status[ 'page' ]; + $languages = $status[ 'languages' ]; + $en = Language::factory( 'en' ); + + $newLanguageLinks = []; + + // Batch the Title::exists queries used below + $lb = new LinkBatch(); + foreach ( array_keys( $languages ) as $code ) { + $title = $page->getTitle()->getSubpage( $code ); + $lb->addObj( $title ); + } + $lb->execute(); + + foreach ( $languages as $code => $percentage ) { + $title = $page->getTitle()->getSubpage( $code ); + $key = "x-pagetranslation:{$title->getPrefixedText()}"; + $translatedName = $page->getPageDisplayTitle( $code ) ?: $title->getPrefixedText(); + + if ( $title->exists() ) { + $href = $title->getLocalURL(); + $classes = self::tpProgressIcon( $percentage ); + $title = wfMessage( 'tpt-languages-nonzero' ) + ->params( $translatedName ) + ->numParams( 100 * $percentage ); + } else { + $href = SpecialPage::getTitleFor( 'Translate' )->getLocalURL( [ + 'group' => $page->getMessageGroupId(), + 'language' => $code, + ] ); + $classes = [ 'mw-pt-progress--none' ]; + $title = wfMessage( 'tpt-languages-zero' ); + } + + self::$languageLinkData[ $key ] = [ + 'href' => $href, + 'language' => $code, + 'percentage' => $percentage, + 'classes' => $classes, + 'autonym' => $en->ucfirst( Language::fetchLanguageName( $code ) ), + 'title' => $title, + ]; + + $newLanguageLinks[ $key ] = self::$languageLinkData[ $key ][ 'autonym' ]; + } + + asort( $newLanguageLinks ); + $languageLinks = array_merge( array_keys( $newLanguageLinks ), $languageLinks ); + } + + /** + * Hooks: SkinTemplateGetLanguageLink + * @param array &$link + * @param Title $linkTitle + * @param Title $pageTitle + * @param OutputPage $out + */ + public static function formatLanguageLink( + array &$link, + Title $linkTitle, + Title $pageTitle, + OutputPage $out + ) { + if ( substr( $link[ 'text' ], 0, 18 ) !== 'x-pagetranslation:' ) { + return; + } + + if ( !isset( self::$languageLinkData[ $link[ 'text' ] ] ) ) { + return; + } + + $data = self::$languageLinkData[ $link[ 'text' ] ]; + + $link[ 'class' ] .= ' ' . implode( ' ', $data[ 'classes' ] ); + $link[ 'href' ] = $data[ 'href' ]; + $link[ 'text' ] = $data[ 'autonym' ]; + $link[ 'title' ] = $data[ 'title' ]->inLanguage( $out->getLanguage()->getCode() )->text(); + $link[ 'lang'] = LanguageCode::bcp47( $data[ 'language' ] ); + $link[ 'hreflang'] = LanguageCode::bcp47( $data[ 'language' ] ); + + $out->addModuleStyles( 'ext.translate.tag.languages' ); + } + + /** + * Display nice error when editing content. + * Hook: EditFilterMergedContent + * @param IContextSource $context + * @param Content $content + * @param Status $status + * @param string $summary + * @return true + */ + public static function tpSyntaxCheckForEditContent( $context, $content, $status, $summary ) { + if ( !$content instanceof TextContent ) { + return true; // whatever. + } + + $text = $content->getNativeData(); + // See T154500 + $text = str_replace( [ "\r\n", "\r" ], "\n", rtrim( $text ) ); + $title = $context->getTitle(); + + $e = self::tpSyntaxError( $title, $text ); + + if ( $e ) { + $msg = $e->getMsg(); + // $msg is an array containing a message key followed by any parameters. + // @todo Use Message object instead. + + call_user_func_array( [ $status, 'fatal' ], $msg ); + } + + return true; + } + + /** + * Returns any syntax error. + * @param Title $title + * @param string $text + * @return null|TPException + */ + protected static function tpSyntaxError( $title, $text ) { + if ( strpos( $text, '<translate>' ) === false ) { + return null; + } + + $page = TranslatablePage::newFromText( $title, $text ); + try { + $page->getParse(); + + return null; + } catch ( TPException $e ) { + return $e; + } + } + + /** + * When attempting to save, last resort. Edit page would only display + * edit conflict if there wasn't tpSyntaxCheckForEditPage. + * Hook: PageContentSave + * @param WikiPage $wikiPage + * @param User $user + * @param Content $content + * @param string $summary + * @param bool $minor + * @param string $_1 + * @param bool $_2 + * @param int $flags + * @param Status $status + * @return true + */ + public static function tpSyntaxCheck( WikiPage $wikiPage, $user, $content, $summary, + $minor, $_1, $_2, $flags, $status + ) { + if ( $content instanceof TextContent ) { + $text = $content->getNativeData(); + // See T154500 + $text = str_replace( [ "\r\n", "\r" ], "\n", rtrim( $text ) ); + } else { + // Screw it, not interested + return true; + } + + // Quick escape on normal pages + if ( strpos( $text, '<translate>' ) === false ) { + return true; + } + + $page = TranslatablePage::newFromText( $wikiPage->getTitle(), $text ); + try { + $page->getParse(); + } catch ( TPException $e ) { + call_user_func_array( [ $status, 'fatal' ], $e->getMsg() ); + + return false; + } + + return true; + } + + /** + * Hook: PageContentSaveComplete + * @param WikiPage $wikiPage + * @param User $user + * @param Content $content + * @param string $summary + * @param bool $minor + * @param string $_1 + * @param bool $_2 + * @param int $flags + * @param Revision $revision + * @return true + */ + public static function addTranstag( WikiPage $wikiPage, $user, $content, $summary, + $minor, $_1, $_2, $flags, $revision + ) { + // We are not interested in null revisions + if ( $revision === null ) { + return true; + } + + if ( $content instanceof TextContent ) { + $text = $content->getNativeData(); + } else { + // Screw it, not interested + return true; + } + + // Quick escape on normal pages + if ( strpos( $text, '</translate>' ) === false ) { + return true; + } + + // Add the ready tag + $page = TranslatablePage::newFromTitle( $wikiPage->getTitle() ); + $page->addReadyTag( $revision->getId() ); + + return true; + } + + /** + * Page moving and page protection (and possibly other things) creates null + * revisions. These revisions re-use the previous text already stored in + * the database. Those however do not trigger re-parsing of the page and + * thus the ready tag is not updated. This watches for new revisions, + * checks if they reuse existing text, checks whether the parent version + * is the latest version and has a ready tag. If that is the case, + * also adds a ready tag for the new revision (which is safe, because + * the text hasn't changed). The interface will say that there has been + * a change, but shows no change in the content. This lets the user to + * update the translation pages in the case, the non-text changes affect + * the rendering of translation pages. I'm not aware of any such cases + * at the moment. + * Hook: RevisionInsertComplete + * @since 2012-05-08 + * @param Revision $rev + * @param string $text + * @param int $flags + * @return true + */ + public static function updateTranstagOnNullRevisions( Revision $rev, $text, $flags ) { + $title = $rev->getTitle(); + + $newRevId = $rev->getId(); + $oldRevId = $rev->getParentId(); + $newTextId = $rev->getTextId(); + + /* This hook doesn't provide any way to detech null revisions + * without extra query */ + $dbw = wfGetDB( DB_MASTER ); + $table = 'revision'; + $field = 'rev_text_id'; + $conds = [ + 'rev_page' => $rev->getPage(), + 'rev_id' => $oldRevId, + ]; + // FIXME: optimize away this query. Bug T38588. + $oldTextId = $dbw->selectField( $table, $field, $conds, __METHOD__ ); + + if ( (string)$newTextId !== (string)$oldTextId ) { + // Not a null revision, bail out. + return true; + } + + $page = TranslatablePage::newFromTitle( $title ); + if ( $page->getReadyTag() === $oldRevId ) { + $page->addReadyTag( $newRevId ); + } + + return true; + } + + /** + * Prevent editing of certain pages in Translations namespace. + * Hook: getUserPermissionsErrorsExpensive + * + * @param Title $title + * @param User $user + * @param string $action + * @param mixed &$result + * @return bool + */ + public static function onGetUserPermissionsErrorsExpensive( Title $title, User $user, + $action, &$result + ) { + $handle = new MessageHandle( $title ); + + // Check only when someone tries to edit (or create) page translation messages + if ( $action !== 'edit' || !$handle->isPageTranslation() ) { + return true; + } + + if ( !$handle->isValid() ) { + // Don't allow editing invalid messages that do not belong to any translatable page + $result = [ 'tpt-unknown-page' ]; + return false; + } + + $error = self::getTranslationRestrictions( $handle ); + if ( count( $error ) ) { + $result = $error; + return false; + } + + return true; + } + + /** + * Prevent editing of restricted languages when prioritized. + * + * @param MessageHandle $handle + * @return array array containing error message if restricted, empty otherwise + */ + private static function getTranslationRestrictions( MessageHandle $handle ) { + global $wgTranslateDocumentationLanguageCode; + + // Allow adding message documentation even when translation is restricted + if ( $handle->getCode() === $wgTranslateDocumentationLanguageCode ) { + return []; + } + + // Get the primary group id + $ids = $handle->getGroupIds(); + $groupId = $ids[0]; + + // Check if anything is prevented for the group in the first place + $force = TranslateMetadata::get( $groupId, 'priorityforce' ); + if ( $force !== 'on' ) { + return []; + } + + // And finally check whether the language is not included in whitelist + $languages = TranslateMetadata::get( $groupId, 'prioritylangs' ); + $filter = array_flip( explode( ',', $languages ) ); + if ( !isset( $filter[$handle->getCode()] ) ) { + // @todo Default reason if none provided + $reason = TranslateMetadata::get( $groupId, 'priorityreason' ); + return [ 'tpt-translation-restricted', $reason ]; + } + + return []; + } + + /** + * Prevent editing of translation pages directly. + * Hook: getUserPermissionsErrorsExpensive + * @param Title $title + * @param User $user + * @param string $action + * @param bool &$result + * @return bool + */ + public static function preventDirectEditing( Title $title, User $user, $action, &$result ) { + if ( self::$allowTargetEdit ) { + return true; + } + + $whitelist = [ + 'read', 'delete', 'undelete', 'deletedtext', 'deletedhistory', + 'review', // FlaggedRevs + ]; + if ( in_array( $action, $whitelist ) ) { + return true; + } + + $page = TranslatablePage::isTranslationPage( $title ); + if ( $page !== false && $page->getMarkedTag() ) { + list( , $code ) = TranslateUtils::figureMessage( $title->getText() ); + $result = [ + 'tpt-target-page', + ':' . $page->getTitle()->getPrefixedText(), + // This url shouldn't get cached + wfExpandUrl( $page->getTranslationUrl( $code ) ) + ]; + + return false; + } + + return true; + } + + /** + * Prevent patrol links from appearing on translation pages. + * Hook: getUserPermissionsErrors + * + * @param Title $title + * @param User $user + * @param string $action + * @param mixed &$result + * + * @return bool + */ + public static function preventPatrolling( Title $title, User $user, $action, &$result ) { + if ( $action !== 'patrol' ) { + return true; + } + + $page = TranslatablePage::isTranslationPage( $title ); + + if ( $page !== false ) { + $result = [ 'tpt-patrolling-blocked' ]; + return false; + } + + return true; + } + + /** + * Redirects the delete action to our own for translatable pages. + * Hook: ArticleConfirmDelete + * + * @param Article $article + * @param OutputPage $out + * @param string &$reason + * + * @return bool + */ + public static function disableDelete( $article, $out, &$reason ) { + $title = $article->getTitle(); + $translatablePage = TranslatablePage::newFromTitle( $title ); + + if ( + $translatablePage->getMarkedTag() !== false || + TranslatablePage::isTranslationPage( $title ) + ) { + $new = SpecialPage::getTitleFor( + 'PageTranslationDeletePage', + $title->getPrefixedText() + ); + $out->redirect( $new->getFullURL() ); + } + + return true; + } + + /** + * Hook: ArticleViewHeader + * + * @param Article &$article + * @param bool &$outputDone + * @param bool &$pcache + * @return bool + */ + public static function translatablePageHeader( &$article, &$outputDone, &$pcache ) { + if ( $article->getOldID() ) { + return true; + } + + $transPage = TranslatablePage::isTranslationPage( $article->getTitle() ); + $context = $article->getContext(); + if ( $transPage ) { + self::translationPageHeader( $context, $transPage ); + } else { + // Check for pages that are tagged or marked + self::sourcePageHeader( $context ); + } + + return true; + } + + protected static function sourcePageHeader( IContextSource $context ) { + $language = $context->getLanguage(); + $title = $context->getTitle(); + + $page = TranslatablePage::newFromTitle( $title ); + + $marked = $page->getMarkedTag(); + $ready = $page->getReadyTag(); + $latest = $title->getLatestRevID(); + + $actions = []; + if ( $marked && $context->getUser()->isAllowed( 'translate' ) ) { + $actions[] = self::getTranslateLink( $context, $page, $language->getCode() ); + } + + $hasChanges = $ready === $latest && $marked !== $latest; + if ( $hasChanges ) { + $diffUrl = $title->getFullURL( [ 'oldid' => $marked, 'diff' => $latest ] ); + + if ( $context->getUser()->isAllowed( 'pagetranslation' ) ) { + $pageTranslation = SpecialPage::getTitleFor( 'PageTranslation' ); + $params = [ 'target' => $title->getPrefixedText(), 'do' => 'mark' ]; + + if ( $marked === false ) { + // This page has never been marked + $linkDesc = $context->msg( 'translate-tag-markthis' )->escaped(); + $actions[] = Linker::linkKnown( $pageTranslation, $linkDesc, [], $params ); + } else { + $markUrl = $pageTranslation->getFullURL( $params ); + $actions[] = $context->msg( 'translate-tag-markthisagain', $diffUrl, $markUrl ) + ->parse(); + } + } else { + $actions[] = $context->msg( 'translate-tag-hasnew', $diffUrl )->parse(); + } + } + + if ( !count( $actions ) ) { + return; + } + + $header = Html::rawElement( + 'div', + [ + 'class' => 'mw-pt-translate-header noprint nomobile', + 'dir' => $language->getDir(), + 'lang' => $language->getHtmlCode(), + ], + $language->semicolonList( $actions ) + ) . Html::element( 'hr' ); + + $context->getOutput()->addHTML( $header ); + } + + private static function getTranslateLink( + IContextSource $context, TranslatablePage $page, $langCode + ) { + return Linker::linkKnown( + SpecialPage::getTitleFor( 'Translate' ), + $context->msg( 'translate-tag-translate-link-desc' )->escaped(), + [], + [ + 'group' => $page->getMessageGroupId(), + 'language' => $langCode, + 'action' => 'page', + 'filter' => '', + ] + ); + } + + protected static function translationPageHeader( + IContextSource $context, TranslatablePage $page + ) { + global $wgTranslateKeepOutdatedTranslations; + + $title = $context->getTitle(); + if ( !$title->exists() ) { + return; + } + + list( , $code ) = TranslateUtils::figureMessage( $title->getText() ); + + // Get the translation percentage + $pers = $page->getTranslationPercentages(); + $per = 0; + if ( isset( $pers[$code] ) ) { + $per = $pers[$code] * 100; + } + + $language = $context->getLanguage(); + $output = $context->getOutput(); + + if ( $page->getSourceLanguageCode() === $code ) { + // If we are on the source language page, link to translate for user's language + $msg = self::getTranslateLink( $context, $page, $language->getCode() ); + } else { + $url = wfExpandUrl( $page->getTranslationUrl( $code ), PROTO_RELATIVE ); + $msg = $context->msg( 'tpt-translation-intro', + $url, + ':' . $page->getTitle()->getPrefixedText(), + $language->formatNum( $per ) + )->parse(); + } + + $header = Html::rawElement( + 'div', + [ + 'class' => 'mw-pt-translate-header noprint', + 'dir' => $language->getDir(), + 'lang' => $language->getHtmlCode(), + ], + $msg + ) . Html::element( 'hr' ); + + $output->addHTML( $header ); + + if ( $wgTranslateKeepOutdatedTranslations ) { + $groupId = $page->getMessageGroupId(); + // This is already calculated and cached by above call to getTranslationPercentages + $stats = MessageGroupStats::forItem( $groupId, $code ); + if ( $stats[MessageGroupStats::FUZZY] ) { + // Only show if there is fuzzy messages + $wrap = '<div class="mw-pt-translate-header"><span class="mw-translate-fuzzy">$1</span></div>'; + $output->wrapWikiMsg( $wrap, [ 'tpt-translation-intro-fuzzy' ] ); + } + } + } + + /** + * Hook: SpecialPage_initList + * @param array &$list + * @return true + */ + public static function replaceMovePage( &$list ) { + $list['Movepage'] = 'SpecialPageTranslationMovePage'; + + return true; + } + + /** + * Hook: getUserPermissionsErrorsExpensive + * @param Title $title + * @param User $user + * @param string $action + * @param array &$result + * @return bool + */ + public static function lockedPagesCheck( Title $title, User $user, $action, &$result ) { + if ( $action === 'read' ) { + return true; + } + + $cache = wfGetCache( CACHE_ANYTHING ); + $key = wfMemcKey( 'pt-lock', sha1( $title->getPrefixedText() ) ); + if ( $cache->get( $key ) === 'locked' ) { + $result = [ 'pt-locked-page' ]; + + return false; + } + + return true; + } + + /** + * Hook: SkinSubPageSubtitle + * @param array &$subpages + * @param Skin|null $skin + * @param OutputPage $out + * @return bool + */ + public static function replaceSubtitle( &$subpages, Skin $skin = null, OutputPage $out ) { + $isTranslationPage = TranslatablePage::isTranslationPage( $out->getTitle() ); + if ( !$isTranslationPage + && !TranslatablePage::isSourcePage( $out->getTitle() ) + ) { + return true; + } + + // Copied from Skin::subPageSubtitle() + if ( $out->isArticle() && MWNamespace::hasSubpages( $out->getTitle()->getNamespace() ) ) { + $ptext = $out->getTitle()->getPrefixedText(); + if ( strpos( $ptext, '/' ) !== false ) { + $links = explode( '/', $ptext ); + array_pop( $links ); + if ( $isTranslationPage ) { + // Also remove language code page + array_pop( $links ); + } + $c = 0; + $growinglink = ''; + $display = ''; + $lang = $skin->getLanguage(); + + foreach ( $links as $link ) { + $growinglink .= $link; + $display .= $link; + $linkObj = Title::newFromText( $growinglink ); + + if ( is_object( $linkObj ) && $linkObj->isKnown() ) { + $getlink = Linker::linkKnown( + SpecialPage::getTitleFor( 'MyLanguage', $growinglink ), + htmlspecialchars( $display ) + ); + + $c++; + + if ( $c > 1 ) { + $subpages .= $lang->getDirMarkEntity() . $skin->msg( 'pipe-separator' )->escaped(); + } else { + $subpages .= '< '; + } + + $subpages .= $getlink; + $display = ''; + } else { + $display .= '/'; + } + + $growinglink .= '/'; + } + } + + return false; + } + + return true; + } + + /** + * Converts the edit tab (if exists) for translation pages to translate tab. + * Hook: SkinTemplateNavigation + * @since 2013.06 + * @param Skin $skin + * @param array &$tabs + * @return true + */ + public static function translateTab( Skin $skin, array &$tabs ) { + $title = $skin->getTitle(); + $handle = new MessageHandle( $title ); + $code = $handle->getCode(); + $page = TranslatablePage::isTranslationPage( $title ); + if ( !$page ) { + return true; + } + // The source language has a subpage too, but cannot be translated + if ( $page->getSourceLanguageCode() === $code ) { + return true; + } + + if ( isset( $tabs['views']['edit'] ) ) { + $tabs['views']['edit']['text'] = $skin->msg( 'tpt-tab-translate' )->text(); + $tabs['views']['edit']['href'] = $page->getTranslationUrl( $code ); + } + + return true; + } + + /** + * Hook to update source and destination translation pages on moving translation units + * Hook: TitleMoveComplete + * @since 2014.08 + * @param Title $ot + * @param Title $nt + * @param User $user + * @param int $oldid + * @param int $newid + * @param string $reason + */ + public static function onMoveTranslationUnits( Title $ot, Title $nt, User $user, + $oldid, $newid, $reason + ) { + // TranslatablePageMoveJob takes care of handling updates because it performs + // a lot of moves at once. As a performance optimization, skip this hook if + // we detect moves from that job. As there isn't a good way to pass information + // to this hook what originated the move, we use some heuristics. + if ( defined( 'MEDIAWIKI_JOB_RUNNER' ) && $user->equals( FuzzyBot::getUser() ) ) { + return; + } + + $groupLast = null; + foreach ( [ $ot, $nt ] as $title ) { + $handle = new MessageHandle( $title ); + if ( !$handle->isValid() ) { + continue; + } + + // Documentation pages are never translation pages + if ( $handle->isDoc() ) { + continue; + } + + $group = $handle->getGroup(); + if ( !$group instanceof WikiPageMessageGroup ) { + continue; + } + + $language = $handle->getCode(); + + // Ignore pages such as Translations:Page/unit without language code + if ( (string)$language === '' ) { + continue; + } + + // Update the page only once if source and destination units + // belong to the same page + if ( $group !== $groupLast ) { + $groupLast = $group; + $page = TranslatablePage::newFromTitle( $group->getTitle() ); + self::updateTranslationPage( $page, $language, $user, 0, $reason ); + } + } + } + + /** + * Hook to update translation page on deleting a translation unit + * Hook: ArticleDeleteComplete + * @since 2016.05 + * @param WikiPage &$unit + * @param User &$user + * @param string $reason + * @param int $id + * @param Content $content + * @param ManualLogEntry $logEntry + */ + public static function onDeleteTranslationUnit( WikiPage &$unit, User &$user, $reason, + $id, $content, $logEntry + ) { + // Do the update. In case job queue is doing the work, the update is not done here + if ( self::$jobQueueRunning ) { + return; + } + $title = $unit->getTitle(); + + $handle = new MessageHandle( $title ); + if ( !$handle->isValid() ) { + return; + } + + $group = $handle->getGroup(); + if ( !$group instanceof WikiPageMessageGroup ) { + return; + } + + // There could be interfaces which may allow mass deletion (eg. Nuke). Since they could + // delete many units in one request, it may do several unnecessary edits and cause several + // other unnecessary updates to be done slowing down the user. To avoid that, we push this + // to a queue that is run after the current transaction is committed so that we can see the + // version that is after all the deletions has been done. This allows us to do just one edit + // per translation page after the current deletions has been done. This is sort of hackish + // but this is better user experience and is also more efficent. + static $queuedPages = []; + $target = $group->getTitle(); + $langCode = $handle->getCode(); + $targetPage = $target->getSubpage( $langCode )->getPrefixedText(); + + if ( !isset( $queuedPages[ $targetPage ] ) ) { + $queuedPages[ $targetPage ] = true; + $fname = __METHOD__; + + $dbw = wfGetDB( DB_MASTER ); + $dbw->onTransactionIdle( function () use ( $dbw, $queuedPages, $targetPage, + $target, $handle, $langCode, $user, $reason, $fname + ) { + $dbw->startAtomic( $fname ); + + $page = TranslatablePage::newFromTitle( $target ); + + MessageGroupStats::forItem( + $page->getMessageGroupId(), + $langCode, + MessageGroupStats::FLAG_NO_CACHE + ); + + if ( !$handle->isDoc() ) { + // Assume that $user and $reason for the first deletion is the same for all + self::updateTranslationPage( $page, $langCode, $user, 0, $reason ); + } + + // If a unit was deleted after the edit here is done, this allows us + // to add the page back to the queue again and so we can make another + // edit here with the latest changes. + unset( $queuedPages[ $targetPage ] ); + + $dbw->endAtomic( $fname ); + } ); + } + } +} diff --git a/www/wiki/extensions/Translate/tag/PageTranslationLogFormatter.php b/www/wiki/extensions/Translate/tag/PageTranslationLogFormatter.php new file mode 100644 index 00000000..7e0d4938 --- /dev/null +++ b/www/wiki/extensions/Translate/tag/PageTranslationLogFormatter.php @@ -0,0 +1,97 @@ +<?php +/** + * Class for formatting Translate page translation logs. + * + * @file + * @author Niklas Laxström + * @license GPL-2.0-or-later + */ + +/** + * Class for formatting Translate page translation logs. + */ +class PageTranslationLogFormatter extends LogFormatter { + public function getMessageParameters() { + $params = parent::getMessageParameters(); + $legacy = $this->entry->getParameters(); + + $type = $this->entry->getFullType(); + switch ( $type ) { + case 'pagetranslation/mark': + $revision = $legacy['revision']; + + $targetPage = $this->makePageLink( + $this->entry->getTarget(), + [ 'oldid' => $revision ] + ); + + $params[2] = Message::rawParam( $targetPage ); + break; + + case 'pagetranslation/moveok': + case 'pagetranslation/movenok': + case 'pagetranslation/deletefnok': + case 'pagetranslation/deletelnok': + $target = $legacy['target']; + + $moveTarget = $this->makePageLink( Title::newFromText( $target ) ); + $params[3] = Message::rawParam( $moveTarget ); + break; + + case 'pagetranslation/prioritylanguages': + $params[3] = $legacy['force']; + $languages = $legacy['languages']; + if ( $languages !== false ) { + $lang = $this->context->getLanguage(); + + $languages = array_map( + 'trim', + preg_split( '/,/', $languages, -1, PREG_SPLIT_NO_EMPTY ) + ); + $languages = array_map( function ( $code ) use ( $lang ) { + return TranslateUtils::getLanguageName( $code, $lang->getCode() ); + }, $languages ); + + $params[4] = $lang->listToText( $languages ); + } + break; + + case 'pagetranslation/associate': + case 'pagetranslation/dissociate': + $params[3] = $legacy['aggregategroup']; + break; + } + + return $params; + } + + public function getComment() { + $legacy = $this->entry->getParameters(); + if ( isset( $legacy['reason'] ) ) { + $comment = Linker::commentBlock( $legacy['reason'] ); + + // No hard coded spaces thanx + return ltrim( $comment ); + } + + return parent::getComment(); + } + + protected function getMessageKey() { + $key = parent::getMessageKey(); + $type = $this->entry->getFullType(); + + // logentry-pagetranslation-prioritylanguages-unset + // logentry-pagetranslation-prioritylanguages-force + if ( $type === 'pagetranslation/prioritylanguages' ) { + $params = $this->getMessageParameters(); + if ( !isset( $params[4] ) ) { + $key .= '-unset'; + } elseif ( $params['3'] === 'on' ) { + $key .= '-force'; + } + } + + return $key; + } +} diff --git a/www/wiki/extensions/Translate/tag/SpecialPageMigration.php b/www/wiki/extensions/Translate/tag/SpecialPageMigration.php new file mode 100644 index 00000000..dc2daac7 --- /dev/null +++ b/www/wiki/extensions/Translate/tag/SpecialPageMigration.php @@ -0,0 +1,82 @@ +<?php +/** + * Contains code for special page Special:PageMigration + * + * @file + * @author Pratik Lahoti + * @copyright Copyright © 2014-2015 Pratik Lahoti + * @license GPL-2.0-or-later + */ + +class SpecialPageMigration extends SpecialPage { + public function __construct() { + parent::__construct( 'PageMigration', 'pagetranslation' ); + } + + protected function getGroupName() { + return 'wiki'; + } + + public function getDescription() { + return $this->msg( 'pagemigration' )->text(); + } + + public function execute( $par ) { + $request = $this->getRequest(); + $output = $this->getOutput(); + $this->setHeaders(); + $this->checkPermissions(); + $this->outputHeader( 'pagemigration-summary' ); + $output->addModules( 'ext.translate.special.pagemigration' ); + $output->addModuleStyles( [ + 'ext.translate.special.pagemigration.styles', + 'jquery.uls.grid' + ] ); + # Get request data from, e.g. + $param = $request->getText( 'param' ); + # Do stuff + # ... + $out = ''; + $out .= Html::openElement( 'div', [ 'class' => 'grid' ] ); + $out .= Html::openElement( 'div', [ 'class' => 'mw-tpm-sp-error row', + 'id' => 'mw-tpm-sp-error-div' ] ); + $out .= Html::element( 'div', + [ 'class' => 'mw-tpm-sp-error__message five columns hide' ] ); + $out .= Html::closeElement( 'div' ); + $out .= Html::openElement( 'form', [ 'class' => 'mw-tpm-sp-form row', + 'id' => 'mw-tpm-sp-primary-form', 'action' => '' ] ); + $out .= Html::element( 'input', [ 'id' => 'pm-summary', 'type' => 'hidden', + 'value' => $this->msg( 'pm-summary-import' )->inContentLanguage()->text() ] ); + $out .= "\n"; + $out .= Html::element( 'input', [ 'id' => 'title', 'class' => 'mw-searchInput mw-ui-input', + 'data-mw-searchsuggest' => FormatJson::encode( [ + 'wrapAsLink' => false + ] ), 'placeholder' => $this->msg( 'pm-pagetitle-placeholder' )->text() ] ); + $out .= "\n"; + $out .= Html::element( 'input', [ 'id' => 'action-import', + 'class' => 'mw-ui-button mw-ui-progressive', 'type' => 'button', + 'value' => $this->msg( 'pm-import-button-label' )->text() ] ); + $out .= "\n"; + $out .= Html::element( 'input', [ 'id' => 'action-save', + 'class' => 'mw-ui-button mw-ui-progressive hide', 'type' => 'button', + 'value' => $this->msg( 'pm-savepages-button-label' )->text() ] ); + $out .= "\n"; + $out .= Html::element( 'input', [ 'id' => 'action-cancel', + 'class' => 'mw-ui-button mw-ui-quiet hide', 'type' => 'button', + 'value' => $this->msg( 'pm-cancel-button-label' )->text() ] ); + $out .= Html::closeElement( 'form' ); + $out .= Html::element( 'div', [ 'class' => 'mw-tpm-sp-instructions hide' ] ); + $out .= Html::openElement( 'div', [ 'class' => 'mw-tpm-sp-unit-listing' ] ); + $out .= Html::closeElement( 'div' ); + $out .= Html::closeElement( 'div' ); + + $output->addHTML( $out ); + + $nojs = Html::element( + 'div', + [ 'class' => 'tux-nojs errorbox' ], + $this->msg( 'tux-nojs' )->plain() + ); + $output->addHTML( $nojs ); + } +} diff --git a/www/wiki/extensions/Translate/tag/SpecialPagePreparation.php b/www/wiki/extensions/Translate/tag/SpecialPagePreparation.php new file mode 100644 index 00000000..cd854e06 --- /dev/null +++ b/www/wiki/extensions/Translate/tag/SpecialPagePreparation.php @@ -0,0 +1,71 @@ +<?php +/** + * Contains code for special page Special:PagePreparation + * + * @file + * @author Pratik Lahoti + * @copyright Copyright © 2014 Pratik Lahoti + * @license GPL-2.0-or-later + */ + +class SpecialPagePreparation extends SpecialPage { + public function __construct() { + parent::__construct( 'PagePreparation', 'pagetranslation' ); + } + + protected function getGroupName() { + return 'wiki'; + } + + public function execute( $par ) { + $request = $this->getRequest(); + $output = $this->getOutput(); + $this->setHeaders(); + $this->checkPermissions(); + + $inputValue = htmlspecialchars( $request->getText( 'page', $par ) ); + $pagenamePlaceholder = $this->msg( 'pp-pagename-placeholder' )->escaped(); + $prepareButtonValue = $this->msg( 'pp-prepare-button-label' )->escaped(); + $saveButtonValue = $this->msg( 'pp-save-button-label' )->escaped(); + $cancelButtonValue = $this->msg( 'pp-cancel-button-label' )->escaped(); + $summaryValue = $this->msg( 'pp-save-summary' )->inContentLanguage()->escaped(); + $output->addModules( 'ext.translate.special.pagepreparation' ); + $output->addModuleStyles( [ + 'ext.translate.special.pagepreparation.styles', + 'jquery.uls.grid' + ] ); + + $out = ''; + $diff = new DifferenceEngine( $this->getContext() ); + $diffHeader = $diff->addHeader( ' ', $this->msg( 'pp-diff-old-header' )->escaped(), + $this->msg( 'pp-diff-new-header' )->escaped() ); + + $out = <<<HTML +<div class="grid"> + <form class="mw-tpp-sp-form row" name="mw-tpp-sp-input-form" action=""> + <input id="pp-summary" type="hidden" value="{$summaryValue}" /> + <input name="page" id="page" class="mw-searchInput mw-ui-input" + placeholder="{$pagenamePlaceholder}" value="{$inputValue}"/> + <button id="action-prepare" class="mw-ui-button mw-ui-progressive" type="button"> + {$prepareButtonValue}</button> + <button id="action-save" class="mw-ui-button mw-ui-progressive hide" type="button"> + {$saveButtonValue}</button> + <button id="action-cancel" class="mw-ui-button mw-ui-quiet hide" type="button"> + {$cancelButtonValue}</button> + </form> + <div class="messageDiv hide"></div> + <div class="divDiff hide"> + {$diffHeader} + </div> +</div> +HTML; + $output->addHTML( $out ); + + $nojs = Html::element( + 'div', + [ 'class' => 'tux-nojs errorbox' ], + $this->msg( 'tux-nojs' )->plain() + ); + $output->addHTML( $nojs ); + } +} diff --git a/www/wiki/extensions/Translate/tag/SpecialPageTranslation.php b/www/wiki/extensions/Translate/tag/SpecialPageTranslation.php new file mode 100644 index 00000000..377d6c91 --- /dev/null +++ b/www/wiki/extensions/Translate/tag/SpecialPageTranslation.php @@ -0,0 +1,993 @@ +<?php +/** + * Contains logic for special page Special:ImportTranslations. + * + * @file + * @author Niklas Laxström + * @author Siebrand Mazeland + * @license GPL-2.0-or-later + */ + +/** + * A special page for marking revisions of pages for translation. + * + * This page is the main tool for translation administrators in the wiki. + * It will list all pages in their various states and provides actions + * that are suitable for given translatable page. + * + * @ingroup SpecialPage PageTranslation + */ +class SpecialPageTranslation extends SpecialPage { + public function __construct() { + parent::__construct( 'PageTranslation' ); + } + + public function doesWrites() { + return true; + } + + protected function getGroupName() { + return 'pagetools'; + } + + public function execute( $parameters ) { + $this->setHeaders(); + + $user = $this->getUser(); + $request = $this->getRequest(); + + $target = $request->getText( 'target', $parameters ); + $revision = $request->getInt( 'revision', 0 ); + $action = $request->getVal( 'do' ); + $out = $this->getOutput(); + $out->addModules( 'ext.translate.special.pagetranslation' ); + $out->addHelpLink( 'Help:Extension:Translate/Page_translation_example' ); + + if ( $target === '' ) { + $this->listPages(); + + return; + } + + // Anything else than listing the pages need permissions + if ( !$user->isAllowed( 'pagetranslation' ) ) { + throw new PermissionsError( 'pagetranslation' ); + } + + $title = Title::newFromText( $target ); + if ( !$title ) { + $out->addWikiMsg( 'tpt-badtitle' ); + + return; + } elseif ( !$title->exists() ) { + $out->addWikiMsg( 'tpt-nosuchpage', $title->getPrefixedText() ); + + return; + } + + // Check token for all POST actions here + if ( $request->wasPosted() && !$user->matchEditToken( $request->getText( 'token' ) ) ) { + throw new PermissionsError( 'pagetranslation' ); + } + + if ( $action === 'mark' ) { + // Has separate form + $this->onActionMark( $title, $revision ); + + return; + } + + // On GET requests, show form which has token + if ( !$request->wasPosted() ) { + if ( $action === 'unlink' ) { + $this->showUnlinkConfirmation( $title, $target ); + } else { + $params = [ + 'do' => $action, + 'target' => $title->getPrefixedText(), + 'revision' => $revision + ]; + $this->showGenericConfirmation( $params ); + } + + return; + } + + if ( $action === 'discourage' || $action === 'encourage' ) { + $id = TranslatablePage::getMessageGroupIdFromTitle( $title ); + $current = MessageGroups::getPriority( $id ); + + if ( $action === 'encourage' ) { + $new = ''; + } else { + $new = 'discouraged'; + } + + if ( $new !== $current ) { + MessageGroups::setPriority( $id, $new ); + $entry = new ManualLogEntry( 'pagetranslation', $action ); + $entry->setPerformer( $user ); + $entry->setTarget( $title ); + $logid = $entry->insert(); + $entry->publish( $logid ); + } + + $this->listPages(); + + $group = MessageGroups::getGroup( $id ); + $parents = MessageGroups::getSharedGroups( $group ); + MessageGroupStats::clearGroup( $parents ); + + return; + } + + if ( $action === 'unlink' ) { + $page = TranslatablePage::newFromTitle( $title ); + $content = ContentHandler::makeContent( + self::getStrippedSourcePageText( $page->getParse() ), + $title + ); + + $status = WikiPage::factory( $title )->doEditContent( + $content, + $this->msg( 'tpt-unlink-summary' )->inContentLanguage()->text(), + EDIT_FORCE_BOT | EDIT_UPDATE + ); + + if ( !$status->isOK() ) { + $out->wrapWikiMsg( + '<div class="errorbox">$1</div>', + [ 'tpt-edit-failed', $status->getWikiText() ] + ); + + return; + } + + $page = TranslatablePage::newFromTitle( $title ); + $this->unmarkPage( $page, $user ); + $out->wrapWikiMsg( + '<div class="successbox">$1</div>', + [ 'tpt-unmarked', $title->getPrefixedText() ] + ); + $this->listPages(); + + return; + } + + if ( $action === 'unmark' ) { + $page = TranslatablePage::newFromTitle( $title ); + $this->unmarkPage( $page, $user ); + $out->wrapWikiMsg( + '<div class="successbox">$1</div>', + [ 'tpt-unmarked', $title->getPrefixedText() ] + ); + $this->listPages(); + + return; + } + } + + protected function onActionMark( Title $title, $revision ) { + $request = $this->getRequest(); + $out = $this->getOutput(); + + $out->addModuleStyles( 'ext.translate.special.pagetranslation.styles' ); + + if ( $revision === 0 ) { + // Get the latest revision + $revision = (int)$title->getLatestRevID(); + } + + $page = TranslatablePage::newFromRevision( $title, $revision ); + if ( !$page instanceof TranslatablePage ) { + $out->wrapWikiMsg( + '<div class="errorbox">$1</div>', + [ 'tpt-notsuitable', $title->getPrefixedText(), $revision ] + ); + + return; + } + + if ( $revision !== (int)$title->getLatestRevID() ) { + // We do want to notify the reviewer if the underlying page changes during review + $target = $title->getFullURL( [ 'oldid' => $revision ] ); + $link = "<span class='plainlinks'>[$target $revision]</span>"; + $out->wrapWikiMsg( + '<div class="warningbox">$1</div>', + [ 'tpt-oldrevision', $title->getPrefixedText(), $link ] + ); + $this->listPages(); + + return; + } + + $lastRev = $page->getMarkedTag(); + $firstMark = $lastRev === false; + if ( !$firstMark && $lastRev === $revision ) { + $out->wrapWikiMsg( + '<div class="warningbox">$1</div>', + [ 'tpt-already-marked' ] + ); + $this->listPages(); + + return; + } + + // This will modify the sections to include name property + $error = false; + $sections = $this->checkInput( $page, $error ); + + // Non-fatal error which prevents saving + if ( $error === false && $request->wasPosted() ) { + // Check if user wants to translate title + // If not, remove it from the list of sections + if ( !$request->getCheck( 'translatetitle' ) ) { + $sections = array_filter( $sections, function ( $s ) { + return $s->id !== 'Page display title'; + } ); + } + + $err = $this->markForTranslation( $page, $sections ); + + if ( $err ) { + call_user_func_array( [ $out, 'addWikiMsg' ], $err ); + } else { + $this->showSuccess( $page, $firstMark ); + $this->listPages(); + } + + return; + } + + $this->showPage( $page, $sections ); + } + + /** + * Displays success message and other instructions after a page has been marked for translation. + * @param TranslatablePage $page + * @param bool $firstMark true if it is the first time the page is being marked for translation. + */ + public function showSuccess( TranslatablePage $page, $firstMark = false ) { + $titleText = $page->getTitle()->getPrefixedText(); + $num = $this->getLanguage()->formatNum( $page->getParse()->countSections() ); + $link = SpecialPage::getTitleFor( 'Translate' )->getFullURL( [ + 'group' => $page->getMessageGroupId(), + 'action' => 'page', + 'filter' => '', + ] ); + + $this->getOutput()->wrapWikiMsg( + '<div class="successbox">$1</div>', + [ 'tpt-saveok', $titleText, $num, $link ] + ); + + // If the page is being marked for translation for the first time + // add a link to Special:PageMigration. + if ( $firstMark ) { + $this->getOutput()->addWikiMsg( 'tpt-saveok-first' ); + } + + // If TranslationNotifications is installed, and the user can notify + // translators, add a convenience link. + if ( method_exists( 'SpecialNotifyTranslators', 'execute' ) && + $this->getUser()->isAllowed( SpecialNotifyTranslators::$right ) + ) { + $link = SpecialPage::getTitleFor( 'NotifyTranslators' )->getFullURL( + [ 'tpage' => $page->getTitle()->getArticleID() ] ); + $this->getOutput()->addWikiMsg( 'tpt-offer-notify', $link ); + } + } + + protected function showGenericConfirmation( array $params ) { + $formParams = [ + 'method' => 'post', + 'action' => $this->getPageTitle()->getFullURL(), + ]; + + $params['title'] = $this->getPageTitle()->getPrefixedText(); + $params['token'] = $this->getUser()->getEditToken(); + + $hidden = ''; + foreach ( $params as $key => $value ) { + $hidden .= Html::hidden( $key, $value ); + } + + $this->getOutput()->addHTML( + Html::openElement( 'form', $formParams ) . + $hidden . + $this->msg( 'tpt-generic-confirm' )->parseAsBlock() . + Xml::submitButton( + $this->msg( 'tpt-generic-button' )->text(), + [ 'class' => 'mw-ui-button mw-ui-progressive' ] + ) . + Html::closeElement( 'form' ) + ); + } + + protected function showUnlinkConfirmation( Title $target ) { + $formParams = [ + 'method' => 'post', + 'action' => $this->getPageTitle()->getFullURL(), + ]; + + $this->getOutput()->addHTML( + Html::openElement( 'form', $formParams ) . + Html::hidden( 'do', 'unlink' ) . + Html::hidden( 'title', $this->getPageTitle()->getPrefixedText() ) . + Html::hidden( 'target', $target->getPrefixedText() ) . + Html::hidden( 'token', $this->getUser()->getEditToken() ) . + $this->msg( 'tpt-unlink-confirm', $target->getPrefixedText() )->parseAsBlock() . + Xml::submitButton( + $this->msg( 'tpt-unlink-button' )->text(), + [ 'class' => 'mw-ui-button mw-ui-destructive' ] + ) . + Html::closeElement( 'form' ) + ); + } + + protected function unmarkPage( TranslatablePage $page, $user ) { + $page->unmarkTranslatablePage(); + $page->getTitle()->invalidateCache(); + + $entry = new ManualLogEntry( 'pagetranslation', 'unmark' ); + $entry->setPerformer( $user ); + $entry->setTarget( $page->getTitle() ); + $logid = $entry->insert(); + $entry->publish( $logid ); + } + + public function loadPagesFromDB() { + $dbr = TranslateUtils::getSafeReadDB(); + $tables = [ 'page', 'revtag' ]; + $vars = [ + 'page_id', + 'page_title', + 'page_namespace', + 'page_latest', + 'MAX(rt_revision) AS rt_revision', + 'rt_type' + ]; + $conds = [ + 'page_id=rt_page', + 'rt_type' => [ RevTag::getType( 'tp:mark' ), RevTag::getType( 'tp:tag' ) ], + ]; + $options = [ + 'ORDER BY' => 'page_namespace, page_title', + 'GROUP BY' => 'page_id, rt_type', + ]; + $res = $dbr->select( $tables, $vars, $conds, __METHOD__, $options ); + + return $res; + } + + protected function buildPageArray( /*db result*/$res ) { + $pages = []; + foreach ( $res as $r ) { + // We have multiple rows for same page, because of different tags + if ( !isset( $pages[$r->page_id] ) ) { + $pages[$r->page_id] = []; + $title = Title::newFromRow( $r ); + $pages[$r->page_id]['title'] = $title; + $pages[$r->page_id]['latest'] = (int)$title->getLatestRevID(); + } + + $tag = RevTag::typeToTag( $r->rt_type ); + $pages[$r->page_id][$tag] = (int)$r->rt_revision; + } + + return $pages; + } + + /** + * @param array $in + * @return array + */ + protected function classifyPages( array $in ) { + $out = [ + 'proposed' => [], + 'active' => [], + 'broken' => [], + 'discouraged' => [], + ]; + + foreach ( $in as $index => $page ) { + if ( !isset( $page['tp:mark'] ) ) { + // Never marked, check that the latest version is ready + if ( $page['tp:tag'] === $page['latest'] ) { + $out['proposed'][$index] = $page; + } // Otherwise ignore such pages + } elseif ( $page['tp:tag'] === $page['latest'] ) { + // Marked and latest version if fine + $out['active'][$index] = $page; + } else { + // Marked but latest version if not fine + $out['broken'][$index] = $page; + } + } + + // broken and proposed take preference over discouraged status + foreach ( $out['active'] as $index => $page ) { + $id = TranslatablePage::getMessageGroupIdFromTitle( $page['title'] ); + $group = MessageGroups::getGroup( $id ); + if ( MessageGroups::getPriority( $group ) === 'discouraged' ) { + $out['discouraged'][$index] = $page; + unset( $out['active'][$index] ); + } + } + + return $out; + } + + public function listPages() { + $out = $this->getOutput(); + + $res = $this->loadPagesFromDB(); + $allPages = $this->buildPageArray( $res ); + if ( !count( $allPages ) ) { + $out->addWikiMsg( 'tpt-list-nopages' ); + + return; + } + + $lb = new LinkBatch(); + $lb->setCaller( __METHOD__ ); + foreach ( $allPages as $page ) { + $lb->addObj( $page['title'] ); + } + $lb->execute(); + + $types = $this->classifyPages( $allPages ); + + $pages = $types['proposed']; + if ( count( $pages ) ) { + $out->wrapWikiMsg( '== $1 ==', 'tpt-new-pages-title' ); + $out->addWikiMsg( 'tpt-new-pages', count( $pages ) ); + $out->addHTML( '<ol>' ); + foreach ( $pages as $page ) { + $link = Linker::link( $page['title'] ); + $acts = $this->actionLinks( $page, 'proposed' ); + $out->addHTML( "<li>$link $acts</li>" ); + } + $out->addHTML( '</ol>' ); + } + + $pages = $types['active']; + if ( count( $pages ) ) { + $out->wrapWikiMsg( '== $1 ==', 'tpt-old-pages-title' ); + $out->addWikiMsg( 'tpt-old-pages', count( $pages ) ); + $out->addHTML( '<ol>' ); + foreach ( $pages as $page ) { + $link = Linker::link( $page['title'] ); + if ( $page['tp:mark'] !== $page['tp:tag'] ) { + $link = "<strong>$link</strong>"; + } + + $acts = $this->actionLinks( $page, 'active' ); + $out->addHTML( "<li>$link $acts</li>" ); + } + $out->addHTML( '</ol>' ); + } + + $pages = $types['broken']; + if ( count( $pages ) ) { + $out->wrapWikiMsg( '== $1 ==', 'tpt-other-pages-title' ); + $out->addWikiMsg( 'tpt-other-pages', count( $pages ) ); + $out->addHTML( '<ol>' ); + foreach ( $pages as $page ) { + $link = Linker::link( $page['title'] ); + $acts = $this->actionLinks( $page, 'broken' ); + $out->addHTML( "<li>$link $acts</li>" ); + } + $out->addHTML( '</ol>' ); + } + + $pages = $types['discouraged']; + if ( count( $pages ) ) { + $out->wrapWikiMsg( '== $1 ==', 'tpt-discouraged-pages-title' ); + $out->addWikiMsg( 'tpt-discouraged-pages', count( $pages ) ); + $out->addHTML( '<ol>' ); + foreach ( $pages as $page ) { + $link = Linker::link( $page['title'] ); + if ( $page['tp:mark'] !== $page['tp:tag'] ) { + $link = "<strong>$link</strong>"; + } + + $acts = $this->actionLinks( $page, 'discouraged' ); + $out->addHTML( "<li>$link $acts</li>" ); + } + $out->addHTML( '</ol>' ); + } + } + + /** + * @param array $page + * @param string $type + * @return string + */ + protected function actionLinks( array $page, $type ) { + $actions = []; + /** + * @var Title $title + */ + $title = $page['title']; + $user = $this->getUser(); + + // Class to allow one-click POSTs + $js = [ 'class' => 'mw-translate-jspost' ]; + + if ( $user->isAllowed( 'pagetranslation' ) ) { + $pending = $type === 'active' && $page['latest'] !== $page['tp:mark']; + if ( $type === 'proposed' || $pending ) { + $actions[] = Linker::linkKnown( + $this->getPageTitle(), + $this->msg( 'tpt-rev-mark' )->escaped(), + [ 'title' => $this->msg( 'tpt-rev-mark-tooltip' )->text() ], + [ + 'do' => 'mark', + 'target' => $title->getPrefixedText(), + 'revision' => $title->getLatestRevID(), + ] + ); + } + + if ( $type === 'active' ) { + $actions[] = Linker::linkKnown( + $this->getPageTitle(), + $this->msg( 'tpt-rev-discourage' )->escaped(), + [ 'title' => $this->msg( 'tpt-rev-discourage-tooltip' )->text() ] + $js, + [ + 'do' => 'discourage', + 'target' => $title->getPrefixedText(), + 'revision' => -1, + ] + ); + } elseif ( $type === 'discouraged' ) { + $actions[] = Linker::linkKnown( + $this->getPageTitle(), + $this->msg( 'tpt-rev-encourage' )->escaped(), + [ 'title' => $this->msg( 'tpt-rev-encourage-tooltip' )->text() ] + $js, + [ + 'do' => 'encourage', + 'target' => $title->getPrefixedText(), + 'revision' => -1, + ] + ); + } + + if ( $type !== 'proposed' ) { + $actions[] = Linker::linkKnown( + $this->getPageTitle(), + $this->msg( 'tpt-rev-unmark' )->escaped(), + [ 'title' => $this->msg( 'tpt-rev-unmark-tooltip' )->text() ], + [ + 'do' => $type === 'broken' ? 'unmark' : 'unlink', + 'target' => $title->getPrefixedText(), + 'revision' => -1, + ] + ); + } + } + + if ( !count( $actions ) ) { + return ''; + } + + $flattened = $this->getLanguage()->semicolonList( $actions ); + + return Html::rawElement( + 'span', + [ 'class' => 'mw-tpt-actions' ], + $this->msg( 'parentheses' )->rawParams( $flattened )->escaped() + ); + } + + /** + * @param TranslatablePage $page + * @param bool &$error + * @return TPSection[] The array has string keys. + */ + public function checkInput( TranslatablePage $page, &$error ) { + $usedNames = []; + $highest = (int)TranslateMetadata::get( $page->getMessageGroupId(), 'maxid' ); + $parse = $page->getParse(); + $sections = $parse->getSectionsForSave( $highest ); + + foreach ( $sections as $s ) { + // We need to do checks for both new and existing sections. + // Someone might have tampered with the page source adding + // duplicate or invalid markers. + if ( isset( $usedNames[$s->id] ) ) { + $this->getOutput()->addWikiMsg( 'tpt-duplicate', $s->id ); + $error = true; + } + $usedNames[$s->id] = true; + $s->name = $s->id; + } + + return $sections; + } + + /** + * Displays the sections and changes for the user to review + * @param TranslatablePage $page + * @param TPSection[] $sections + */ + public function showPage( TranslatablePage $page, array $sections ) { + $out = $this->getOutput(); + + $out->setSubtitle( Linker::link( $page->getTitle() ) ); + + $out->addWikiMsg( 'tpt-showpage-intro' ); + + $formParams = [ + 'method' => 'post', + 'action' => $this->getPageTitle()->getFullURL(), + 'class' => 'mw-tpt-sp-markform', + ]; + + $out->addHTML( + Xml::openElement( 'form', $formParams ) . + Html::hidden( 'do', 'mark' ) . + Html::hidden( 'title', $this->getPageTitle()->getPrefixedText() ) . + Html::hidden( 'revision', $page->getRevision() ) . + Html::hidden( 'target', $page->getTitle()->getPrefixedText() ) . + Html::hidden( 'token', $this->getUser()->getEditToken() ) + ); + + $out->wrapWikiMsg( '==$1==', 'tpt-sections-oldnew' ); + + $diffOld = $this->msg( 'tpt-diff-old' )->escaped(); + $diffNew = $this->msg( 'tpt-diff-new' )->escaped(); + $hasChanges = false; + + // Check whether page title was previously marked for translation. + // If the page is marked for translation the first time, default to checked. + $defaultChecked = $page->hasPageDisplayTitle(); + + $sourceLanguage = Language::factory( $page->getSourceLanguageCode() ); + + foreach ( $sections as $s ) { + if ( $s->name === 'Page display title' ) { + // Set section type as new if title previously unchecked + $s->type = $defaultChecked ? $s->type : 'new'; + + // Checkbox for page title optional translation + $this->getOutput()->addHTML( Xml::checkLabel( + $this->msg( 'tpt-translate-title' )->text(), + 'translatetitle', + 'mw-translate-title', + $defaultChecked + ) ); + } + + if ( $s->type === 'new' ) { + $hasChanges = true; + $name = $this->msg( 'tpt-section-new', $s->name )->escaped(); + } else { + $name = $this->msg( 'tpt-section', $s->name )->escaped(); + } + + if ( $s->type === 'changed' ) { + $hasChanges = true; + $diff = new DifferenceEngine; + $diff->setTextLanguage( $sourceLanguage ); + $diff->setReducedLineNumbers(); + + $oldContent = ContentHandler::makeContent( $s->getOldText(), $diff->getTitle() ); + $newContent = ContentHandler::makeContent( $s->getText(), $diff->getTitle() ); + + $diff->setContent( $oldContent, $newContent ); + + $text = $diff->getDiff( $diffOld, $diffNew ); + $diffOld = $diffNew = null; + $diff->showDiffStyle(); + + $id = "tpt-sect-{$s->id}-action-nofuzzy"; + $checkLabel = Xml::checkLabel( + $this->msg( 'tpt-action-nofuzzy' )->text(), + $id, + $id, + false + ); + $text = $checkLabel . $text; + } else { + $text = TranslateUtils::convertWhiteSpaceToHTML( $s->getText() ); + } + + # For changed text, the language is set by $diff->setTextLanguage() + $lang = $s->type === 'changed' ? null : $sourceLanguage; + $out->addHTML( MessageWebImporter::makeSectionElement( + $name, + $s->type, + $text, + $lang + ) ); + } + + $deletedSections = $page->getParse()->getDeletedSections(); + if ( count( $deletedSections ) ) { + $hasChanges = true; + $out->wrapWikiMsg( '==$1==', 'tpt-sections-deleted' ); + + /** + * @var TPSection $s + */ + foreach ( $deletedSections as $s ) { + $name = $this->msg( 'tpt-section-deleted', $s->id )->escaped(); + $text = TranslateUtils::convertWhiteSpaceToHTML( $s->getText() ); + $out->addHTML( MessageWebImporter::makeSectionElement( + $name, + $s->type, + $text, + $sourceLanguage + ) ); + } + } + + // Display template changes if applicable + if ( $page->getMarkedTag() !== false ) { + $hasChanges = true; + $newTemplate = $page->getParse()->getTemplatePretty(); + $oldPage = TranslatablePage::newFromRevision( + $page->getTitle(), + $page->getMarkedTag() + ); + $oldTemplate = $oldPage->getParse()->getTemplatePretty(); + + if ( $oldTemplate !== $newTemplate ) { + $out->wrapWikiMsg( '==$1==', 'tpt-sections-template' ); + + $diff = new DifferenceEngine; + $diff->setTextLanguage( $sourceLanguage ); + + $oldContent = ContentHandler::makeContent( $oldTemplate, $diff->getTitle() ); + $newContent = ContentHandler::makeContent( $newTemplate, $diff->getTitle() ); + + $diff->setContent( $oldContent, $newContent ); + + $text = $diff->getDiff( + $this->msg( 'tpt-diff-old' )->escaped(), + $this->msg( 'tpt-diff-new' )->escaped() + ); + $diff->showDiffStyle(); + $diff->setReducedLineNumbers(); + + $contentParams = [ 'class' => 'mw-tpt-sp-content' ]; + $out->addHTML( Xml::tags( 'div', $contentParams, $text ) ); + } + } + + if ( !$hasChanges ) { + $out->wrapWikiMsg( '<div class="successbox">$1</div>', 'tpt-mark-nochanges' ); + } + + $this->priorityLanguagesForm( $page ); + + $out->addHTML( + Xml::submitButton( $this->msg( 'tpt-submit' )->text() ) . + Xml::closeElement( 'form' ) + ); + } + + /** + * @param TranslatablePage $page + */ + protected function priorityLanguagesForm( TranslatablePage $page ) { + global $wgContLang; + + $groupId = $page->getMessageGroupId(); + $this->getOutput()->wrapWikiMsg( '==$1==', 'tpt-sections-prioritylangs' ); + + $langSelector = Xml::languageSelector( + $wgContLang->getCode(), + false, + $this->getLanguage()->getCode() + ); + + $hLangs = Xml::inputLabelSep( + $this->msg( 'tpt-select-prioritylangs' )->text(), + 'prioritylangs', // name + 'tpt-prioritylangs', // id + 50, + TranslateMetadata::get( $groupId, 'prioritylangs' ) + ); + + $hForce = Xml::checkLabel( + $this->msg( 'tpt-select-prioritylangs-force' )->text(), + 'forcelimit', // name + 'tpt-priority-forcelimit', // id + TranslateMetadata::get( $groupId, 'priorityforce' ) === 'on' + ); + + $hReason = Xml::inputLabelSep( + $this->msg( 'tpt-select-prioritylangs-reason' )->text(), + 'priorityreason', // name + 'tpt-priority-reason', // id + 50, // size + TranslateMetadata::get( $groupId, 'priorityreason' ) + ); + + $this->getOutput()->addHTML( + '<table>' . + '<tr>' . + "<td class='mw-label'>$hLangs[0]</td>" . + "<td class='mw-input'>$hLangs[1]$langSelector[1]</td>" . + '</tr>' . + "<tr><td></td><td class='mw-inout'>$hForce</td></tr>" . + '<tr>' . + "<td class='mw-label'>$hReason[0]</td>" . + "<td class='mw-input'>$hReason[1]</td>" . + '</tr>' . + '</table>' + ); + } + + /** + * This function does the heavy duty of marking a page. + * - Updates the source page with section markers. + * - Updates translate_sections table + * - Updates revtags table + * - Setups renderjobs to update the translation pages + * - Invalidates caches + * @param TranslatablePage $page + * @param TPSection[] $sections + * @return array|bool + */ + public function markForTranslation( TranslatablePage $page, array $sections ) { + // Add the section markers to the source page + $wikiPage = WikiPage::factory( $page->getTitle() ); + $content = ContentHandler::makeContent( + $page->getParse()->getSourcePageText(), + $page->getTitle() + ); + + $status = $wikiPage->doEditContent( + $content, + $this->msg( 'tpt-mark-summary' )->inContentLanguage()->text(), + EDIT_FORCE_BOT | EDIT_UPDATE + ); + + if ( !$status->isOK() ) { + return [ 'tpt-edit-failed', $status->getWikiText() ]; + } + + $newrevision = $status->value['revision']; + + // In theory it is either null or Revision object, + // never revision object with null id, but who knows + if ( $newrevision instanceof Revision ) { + $newrevision = $newrevision->getId(); + } + + if ( $newrevision === null ) { + // Probably a no-change edit, so no new revision was assigned. + // Get the latest revision manually + $newrevision = $page->getTitle()->getLatestRevID(); + } + + $inserts = []; + $changed = []; + $maxid = (int)TranslateMetadata::get( $page->getMessageGroupId(), 'maxid' ); + + $pageId = $page->getTitle()->getArticleID(); + /** + * @var TPSection $s + */ + foreach ( array_values( $sections ) as $index => $s ) { + $maxid = max( $maxid, (int)$s->name ); + $changed[] = $s->name; + + if ( $this->getRequest()->getCheck( "tpt-sect-{$s->id}-action-nofuzzy" ) ) { + // TranslationsUpdateJob will only fuzzy when type is changed + $s->type = 'old'; + } + + $inserts[] = [ + 'trs_page' => $pageId, + 'trs_key' => $s->name, + 'trs_text' => $s->getText(), + 'trs_order' => $index + ]; + } + + $dbw = wfGetDB( DB_MASTER ); + $dbw->delete( + 'translate_sections', + [ 'trs_page' => $page->getTitle()->getArticleID() ], + __METHOD__ + ); + $dbw->insert( 'translate_sections', $inserts, __METHOD__ ); + TranslateMetadata::set( $page->getMessageGroupId(), 'maxid', $maxid ); + + $page->addMarkedTag( $newrevision ); + MessageGroups::singleton()->recache(); + + $job = TranslationsUpdateJob::newFromPage( $page, $sections ); + JobQueueGroup::singleton()->push( $job ); + + // Logging + $this->handlePriorityLanguages( $this->getRequest(), $page ); + + $entry = new ManualLogEntry( 'pagetranslation', 'mark' ); + $entry->setPerformer( $this->getUser() ); + $entry->setTarget( $page->getTitle() ); + $entry->setParameters( [ + 'revision' => $newrevision, + 'changed' => count( $changed ), + ] ); + $logid = $entry->insert(); + $entry->publish( $logid ); + + // Clear more caches + $page->getTitle()->invalidateCache(); + + return false; + } + + /** + * @param WebRequest $request + * @param TranslatablePage $page + */ + protected function handlePriorityLanguages( WebRequest $request, TranslatablePage $page ) { + // new priority languages + $npLangs = rtrim( trim( $request->getVal( 'prioritylangs' ) ), ',' ); + $npForce = $request->getCheck( 'forcelimit' ) ? 'on' : 'off'; + $npReason = trim( $request->getText( 'priorityreason' ) ); + + // Normalize + $npLangs = array_map( 'trim', explode( ',', $npLangs ) ); + $npLangs = array_unique( $npLangs ); + // Remove invalid language codes. + $languages = Language::fetchLanguageNames(); + foreach ( $npLangs as $index => $language ) { + if ( !array_key_exists( $language, $languages ) ) { + unset( $npLangs[$index] ); + } + } + $npLangs = implode( ',', $npLangs ); + if ( $npLangs === '' ) { + $npLangs = false; + $npForce = false; + $npReason = false; + } + + $groupId = $page->getMessageGroupId(); + // old priority languages + $opLangs = TranslateMetadata::get( $groupId, 'prioritylangs' ); + $opForce = TranslateMetadata::get( $groupId, 'priorityforce' ); + $opReason = TranslateMetadata::get( $groupId, 'priorityreason' ); + + TranslateMetadata::set( $groupId, 'prioritylangs', $npLangs ); + TranslateMetadata::set( $groupId, 'priorityforce', $npForce ); + TranslateMetadata::set( $groupId, 'priorityreason', $npReason ); + + if ( $opLangs !== $npLangs || $opForce !== $npForce || $opReason !== $npReason ) { + $params = [ + 'languages' => $npLangs, + 'force' => $npForce, + 'reason' => $npReason, + ]; + + $entry = new ManualLogEntry( 'pagetranslation', 'prioritylanguages' ); + $entry->setPerformer( $this->getUser() ); + $entry->setTarget( $page->getTitle() ); + $entry->setParameters( $params ); + $entry->setComment( $npReason ); + $logid = $entry->insert(); + $entry->publish( $logid ); + } + } + + /** + * Returns the source page without any translation markup. + * + * @param TPParse $parse + * @return string + * @since 2014.09 + */ + public static function getStrippedSourcePageText( TPParse $parse ) { + $text = $parse->getTranslationPageText( [] ); + $text = preg_replace( '~<languages\s*/>\n?~s', '', $text ); + return $text; + } +} diff --git a/www/wiki/extensions/Translate/tag/SpecialPageTranslationDeletePage.php b/www/wiki/extensions/Translate/tag/SpecialPageTranslationDeletePage.php new file mode 100644 index 00000000..168d574a --- /dev/null +++ b/www/wiki/extensions/Translate/tag/SpecialPageTranslationDeletePage.php @@ -0,0 +1,456 @@ +<?php +/** + * Special page which enables deleting translations of translatable pages + * + * @file + * @author Niklas Laxström + * @license GPL-2.0-or-later + */ + +/** + * Special page which enables deleting translations of translatable pages + * + * @ingroup SpecialPage PageTranslation + */ +class SpecialPageTranslationDeletePage extends SpecialPage { + // Basic form parameters both as text and as titles + protected $text; + + /** + * @var Title + */ + protected $title; + + // Other form parameters + /// 'check' or 'perform' + protected $subaction; + + /// There must be reason for everything. + protected $reason; + + /// Allow skipping non-translation subpages. + protected $doSubpages = false; + + /** + * @var TranslatablePage + */ + protected $page; + + /// Contains the language code if we are working with translation page + protected $code; + + protected $sectionPages; + + protected $translationPages; + + public function __construct() { + parent::__construct( 'PageTranslationDeletePage', 'pagetranslation' ); + } + + public function doesWrites() { + return true; + } + + public function isListed() { + return false; + } + + public function execute( $par ) { + $request = $this->getRequest(); + + $par = (string)$par; + + // Yes, the use of getVal() and getText() is wanted, see bug T22365 + $this->text = $request->getVal( 'wpTitle', $par ); + $this->title = Title::newFromText( $this->text ); + $this->reason = $request->getText( 'wpReason' ); + // Checkboxes that default being checked are tricky + $this->doSubpages = $request->getBool( 'subpages', !$request->wasPosted() ); + + $user = $this->getUser(); + + if ( $this->doBasicChecks() !== true ) { + return; + } + + $out = $this->getOutput(); + + // Real stuff starts here + if ( TranslatablePage::isSourcePage( $this->title ) ) { + $title = $this->msg( 'pt-deletepage-full-title', $this->title->getPrefixedText() ); + $out->setPageTitle( $title ); + + $this->code = ''; + $this->page = TranslatablePage::newFromTitle( $this->title ); + } else { + $page = TranslatablePage::isTranslationPage( $this->title ); + if ( $page ) { + $title = $this->msg( 'pt-deletepage-lang-title', $this->title->getPrefixedText() ); + $out->setPageTitle( $title ); + + list( , $this->code ) = TranslateUtils::figureMessage( $this->title->getText() ); + $this->page = $page; + } else { + throw new ErrorPageError( + 'pt-deletepage-invalid-title', + 'pt-deletepage-invalid-text' + ); + } + } + + if ( !$user->isAllowed( 'pagetranslation' ) ) { + throw new PermissionsError( 'pagetranslation' ); + } + + // Is there really no better way to do this? + $subactionText = $request->getText( 'subaction' ); + switch ( $subactionText ) { + case $this->msg( 'pt-deletepage-action-check' )->text(): + $subaction = 'check'; + break; + case $this->msg( 'pt-deletepage-action-perform' )->text(): + $subaction = 'perform'; + break; + case $this->msg( 'pt-deletepage-action-other' )->text(): + $subaction = ''; + break; + default: + $subaction = ''; + } + + if ( $subaction === 'check' && $this->checkToken() && $request->wasPosted() ) { + $this->showConfirmation(); + } elseif ( $subaction === 'perform' && $this->checkToken() && $request->wasPosted() ) { + $this->performAction(); + } else { + $this->showForm(); + } + } + + /** + * Do the basic checks whether moving is possible and whether + * the input looks anywhere near sane. + * @throws PermissionsError|ErrorPageError|ReadOnlyError + * @return bool + */ + protected function doBasicChecks() { + # Check rights + if ( !$this->userCanExecute( $this->getUser() ) ) { + $this->displayRestrictionError(); + } + + if ( $this->title === null ) { + throw new ErrorPageError( 'notargettitle', 'notargettext' ); + } + + if ( !$this->title->exists() ) { + throw new ErrorPageError( 'nopagetitle', 'nopagetext' ); + } + + $permissionErrors = $this->title->getUserPermissionsErrors( 'delete', $this->getUser() ); + if ( count( $permissionErrors ) ) { + throw new PermissionsError( 'delete', $permissionErrors ); + } + + # Check for database lock + if ( wfReadOnly() ) { + throw new ReadOnlyError; + } + + // Let the caller know it's safe to continue + return true; + } + + /** + * Checks token. Use before real actions happen. Have to use wpEditToken + * for compatibility for SpecialMovepage.php. + * @return bool + */ + protected function checkToken() { + return $this->getUser()->matchEditToken( $this->getRequest()->getVal( 'wpEditToken' ) ); + } + + /** + * The query form. + */ + protected function showForm() { + $this->getOutput()->addWikiMsg( 'pt-deletepage-intro' ); + + $formDescriptor = [ + 'wpTitle' => [ + 'type' => 'text', + 'name' => 'wpTitle', + 'label' => $this->msg( 'pt-deletepage-current' )->text(), + 'size' => 30, + 'default' => $this->text, + ], + 'wpReason' => [ + 'type' => 'text', + 'name' => 'wpReason', + 'label' => $this->msg( 'pt-deletepage-reason' )->text(), + 'size' => 60, + 'default' => $this->reason, + ] + ]; + + $htmlForm = HTMLForm::factory( 'ooui', $formDescriptor, $this->getContext() ); + $htmlForm + ->addHiddenField( 'wpEditToken', $this->getUser()->getEditToken() ) + ->setMethod( 'post' ) + ->setAction( $this->getPageTitle( $this->text )->getLocalURL() ) + ->setSubmitName( 'subaction' ) + ->setSubmitTextMsg( 'pt-deletepage-action-check' ) + ->setWrapperLegendMsg( 'pt-deletepage-any-legend' ) + ->prepareForm() + ->displayForm( false ); + } + + /** + * The second form, which still allows changing some things. + * Lists all the action which would take place. + */ + protected function showConfirmation() { + $out = $this->getOutput(); + $count = 0; + + $out->addWikiMsg( 'pt-deletepage-intro' ); + + $out->wrapWikiMsg( '== $1 ==', 'pt-deletepage-list-pages' ); + if ( !$this->singleLanguage() ) { + $count++; + TranslateUtils::addWikiTextAsInterface( + $out, + $this->getChangeLine( $this->title ) + ); + } + + $out->wrapWikiMsg( '=== $1 ===', 'pt-deletepage-list-translation' ); + $translationPages = $this->getTranslationPages(); + $lines = []; + foreach ( $translationPages as $old ) { + $count++; + $lines[] = $this->getChangeLine( $old ); + } + TranslateUtils::addWikiTextAsInterface( $out, implode( "\n", $lines ) ); + + $out->wrapWikiMsg( '=== $1 ===', 'pt-deletepage-list-section' ); + $sectionPages = $this->getSectionPages(); + $lines = []; + foreach ( $sectionPages as $old ) { + $count++; + $lines[] = $this->getChangeLine( $old ); + } + TranslateUtils::addWikiTextAsInterface( $out, implode( "\n", $lines ) ); + + $out->wrapWikiMsg( '=== $1 ===', 'pt-deletepage-list-other' ); + $subpages = $this->getSubpages(); + $lines = []; + foreach ( $subpages as $old ) { + if ( TranslatablePage::isTranslationPage( $old ) ) { + continue; + } + + if ( $this->doSubpages ) { + $count++; + } + + $lines[] = $this->getChangeLine( $old, $this->doSubpages ); + } + TranslateUtils::addWikiTextAsInterface( $out, implode( "\n", $lines ) ); + + TranslateUtils::addWikiTextAsInterface( $out, "----\n" ); + $out->addWikiMsg( 'pt-deletepage-list-count', $this->getLanguage()->formatNum( $count ) ); + + $formDescriptor = [ + 'wpTitle' => [ + 'type' => 'text', + 'name' => 'wpTitle', + 'label' => $this->msg( 'pt-deletepage-current' )->text(), + 'size' => 30, + 'default' => $this->text, + 'readonly' => true, + ], + 'wpReason' => [ + 'type' => 'text', + 'name' => 'wpReason', + 'label' => $this->msg( 'pt-deletepage-reason' )->text(), + 'size' => 60, + 'default' => $this->reason, + ], + 'subpages' => [ + 'type' => 'check', + 'name' => 'subpages', + 'id' => 'mw-subpages', + 'label' => $this->msg( 'pt-deletepage-subpages' )->text(), + 'default' => $this->doSubpages, + ] + ]; + + $htmlForm = HTMLForm::factory( 'ooui', $formDescriptor, $this->getContext() ); + + if ( $this->singleLanguage() ) { + $htmlForm->setWrapperLegendMsg( 'pt-deletepage-lang-legend' ); + } else { + $htmlForm->setWrapperLegendMsg( 'pt-deletepage-full-legend' ); + } + + $htmlForm + ->addHiddenField( 'wpEditToken', $this->getUser()->getEditToken() ) + ->setMethod( 'post' ) + ->setAction( $this->getPageTitle( $this->text )->getLocalURL() ) + ->setSubmitTextMsg( 'pt-deletepage-action-perform' ) + ->setSubmitName( 'subaction' ) + ->addButton( [ + 'name' => 'subaction', + 'value' => $this->msg( 'pt-deletepage-action-other' )->text(), + ] ) + ->prepareForm() + ->displayForm( false ); + } + + /** + * @param Title $title + * @param bool $enabled + * @return string One line of wikitext, without trailing newline. + */ + protected function getChangeLine( $title, $enabled = true ) { + if ( $enabled ) { + return '* ' . $title->getPrefixedText(); + } else { + return '* <s>' . $title->getPrefixedText() . '</s>'; + } + } + + protected function performAction() { + $jobs = []; + $target = $this->title; + $base = $this->title->getPrefixedText(); + + $translationPages = $this->getTranslationPages(); + $user = $this->getUser(); + foreach ( $translationPages as $old ) { + $jobs[$old->getPrefixedText()] = TranslateDeleteJob::newJob( + $old, + $base, + !$this->singleLanguage(), + $user, + $this->reason + ); + } + + $sectionPages = $this->getSectionPages(); + foreach ( $sectionPages as $old ) { + $jobs[$old->getPrefixedText()] = TranslateDeleteJob::newJob( + $old, + $base, + !$this->singleLanguage(), + $user, + $this->reason + ); + } + + if ( !$this->doSubpages ) { + $subpages = $this->getSubpages(); + foreach ( $subpages as $old ) { + if ( TranslatablePage::isTranslationPage( $old ) ) { + continue; + } + + $jobs[$old->getPrefixedText()] = TranslateDeleteJob::newJob( + $old, + $base, + !$this->singleLanguage(), + $user, + $this->reason + ); + } + } + + JobQueueGroup::singleton()->push( $jobs ); + + $cache = wfGetCache( CACHE_DB ); + $cache->set( + wfMemcKey( 'pt-base', $target->getPrefixedText() ), + array_keys( $jobs ), + 60 * 60 * 6 + ); + + if ( !$this->singleLanguage() ) { + $this->page->unmarkTranslatablePage(); + } + + $this->clearMetadata(); + MessageGroups::singleton()->recache(); + MessageIndexRebuildJob::newJob()->insertIntoJobQueue(); + + $this->getOutput()->addWikiMsg( 'pt-deletepage-started' ); + } + + protected function clearMetadata() { + // remove the entries from metadata table. + $groupId = $this->page->getMessageGroupId(); + TranslateMetadata::set( $groupId, 'prioritylangs', false ); + TranslateMetadata::set( $groupId, 'priorityforce', false ); + TranslateMetadata::set( $groupId, 'priorityreason', false ); + // remove the page from aggregate groups, if present in any of them. + $aggregateGroups = MessageGroups::getGroupsByType( AggregateMessageGroup::class ); + TranslateMetadata::preloadGroups( array_keys( $aggregateGroups ) ); + foreach ( $aggregateGroups as $group ) { + $subgroups = TranslateMetadata::get( $group->getId(), 'subgroups' ); + if ( $subgroups !== false ) { + $subgroups = explode( ',', $subgroups ); + $subgroups = array_flip( $subgroups ); + if ( isset( $subgroups[$groupId] ) ) { + unset( $subgroups[$groupId] ); + $subgroups = array_flip( $subgroups ); + TranslateMetadata::set( + $group->getId(), + 'subgroups', + implode( ',', $subgroups ) + ); + } + } + } + } + + /** + * Returns all section pages, including those which are currently not active. + * @return Array of titles. + */ + protected function getSectionPages() { + $code = $this->singleLanguage() ? $this->code : false; + + return $this->page->getTranslationUnitPages( 'all', $code ); + } + + /** + * Returns only translation subpages. + * @return Array of titles. + */ + protected function getTranslationPages() { + if ( $this->singleLanguage() ) { + return [ $this->title ]; + } + + if ( !isset( $this->translationPages ) ) { + $this->translationPages = $this->page->getTranslationPages(); + } + + return $this->translationPages; + } + + /** + * Returns all subpages, if the namespace has them enabled. + * @return array|TitleArray Empty array or TitleArray. + */ + protected function getSubpages() { + return $this->title->getSubpages(); + } + + /** + * @return bool + */ + protected function singleLanguage() { + return $this->code !== ''; + } +} diff --git a/www/wiki/extensions/Translate/tag/SpecialPageTranslationMovePage.php b/www/wiki/extensions/Translate/tag/SpecialPageTranslationMovePage.php new file mode 100644 index 00000000..452f65fb --- /dev/null +++ b/www/wiki/extensions/Translate/tag/SpecialPageTranslationMovePage.php @@ -0,0 +1,628 @@ +<?php +/** + * Contains class to override Special:MovePage for page translation. + * + * @file + * @author Niklas Laxström + * @license GPL-2.0-or-later + */ + +/** + * Overrides Special:Movepage to to allow renaming a page translation page and + * all related translations and derivative pages. + * + * @ingroup SpecialPage PageTranslation + */ +class SpecialPageTranslationMovePage extends MovePageForm { + // Basic form parameters both as text and as titles + protected $newText, $oldText; + + /** + * @var Title + */ + protected $newTitle, $oldTitle; + + // Other form parameters + /** + * 'check' or 'perform' + */ + protected $subaction; + + /** + * There must be reason for everything. + */ + protected $reason; + + /** + * Allow skipping non-translation subpages. + */ + protected $moveSubpages; + + /** + * @var TranslatablePage instance. + */ + protected $page; + + /** + * Whether MovePageForm extends SpecialPage + */ + protected $old; + + /** + * @var Title[] Cached list of translation pages. Not yet loaded if null. + */ + protected $translationPages; + + /** + * @var Title[] Cached list of section pages. Not yet loaded if null. + */ + protected $sectionPages; + + public function __construct() { + parent::__construct( 'Movepage' ); + } + + public function doesWrites() { + return true; + } + + public function isListed() { + return false; + } + + /** + * Partially copies from SpecialMovepage.php, because it cannot be + * extended in other ways. + * + * @param string|null $par null if subpage not provided, string otherwise + * @throws PermissionsError + */ + public function execute( $par ) { + $request = $this->getRequest(); + $user = $this->getUser(); + + $par = is_null( $par ) ? '' : $par; // Title::newFromText expects strings only + + // Yes, the use of getVal() and getText() is wanted, see bug T22365 + $this->oldText = $request->getVal( 'wpOldTitle', $request->getVal( 'target', $par ) ); + $this->newText = $request->getText( 'wpNewTitle' ); + + $this->oldTitle = Title::newFromText( $this->oldText ); + $this->newTitle = Title::newFromText( $this->newText ); + + $this->reason = $request->getText( 'reason' ); + // Checkboxes that default being checked are tricky + $this->moveSubpages = $request->getBool( 'subpages', !$request->wasPosted() ); + + // This will throw exceptions if there is an error. + $this->doBasicChecks(); + + // Real stuff starts here + $page = TranslatablePage::newFromTitle( $this->oldTitle ); + if ( $page->getMarkedTag() !== false ) { + $this->page = $page; + + $this->getOutput()->setPageTitle( $this->msg( 'pt-movepage-title', $this->oldText ) ); + + if ( !$user->isAllowed( 'pagetranslation' ) ) { + throw new PermissionsError( 'pagetranslation' ); + } + + // Is there really no better way to do this? + $subactionText = $request->getText( 'subaction' ); + switch ( $subactionText ) { + case $this->msg( 'pt-movepage-action-check' )->text(): + $subaction = 'check'; + break; + case $this->msg( 'pt-movepage-action-perform' )->text(): + $subaction = 'perform'; + break; + case $this->msg( 'pt-movepage-action-other' )->text(): + $subaction = ''; + break; + default: + $subaction = ''; + } + + if ( $subaction === 'check' && $this->checkToken() && $request->wasPosted() ) { + $blockers = $this->checkMoveBlockers(); + if ( count( $blockers ) ) { + $this->showErrors( $blockers ); + $this->showForm( [] ); + } else { + $this->showConfirmation(); + } + } elseif ( $subaction === 'perform' && $this->checkToken() && $request->wasPosted() ) { + $this->performAction(); + } else { + $this->showForm( [] ); + } + } else { + // Delegate... don't want to reimplement this + $sp = new MovePageForm(); + $sp->execute( $par ); + } + } + + /** + * Do the basic checks whether moving is possible and whether + * the input looks anywhere near sane. + * @throws PermissionsError|ErrorPageError|ReadOnlyError|ThrottledError + */ + protected function doBasicChecks() { + $this->checkReadOnly(); + + if ( $this->oldTitle === null ) { + throw new ErrorPageError( 'notargettitle', 'notargettext' ); + } + + if ( !$this->oldTitle->exists() ) { + throw new ErrorPageError( 'nopagetitle', 'nopagetext' ); + } + + if ( $this->getUser()->pingLimiter( 'move' ) ) { + throw new ThrottledError; + } + + // Check rights + $permErrors = $this->oldTitle->getUserPermissionsErrors( 'move', $this->getUser() ); + if ( count( $permErrors ) ) { + throw new PermissionsError( 'move', $permErrors ); + } + } + + /** + * Checks token. Use before real actions happen. Have to use wpEditToken + * for compatibility for SpecialMovepage.php. + * + * @return bool + */ + protected function checkToken() { + return $this->getUser()->matchEditToken( $this->getRequest()->getVal( 'wpEditToken' ) ); + } + + /** + * Pretty-print the list of errors. + * @param array $errors Array with message key and parameters + */ + protected function showErrors( array $errors ) { + if ( count( $errors ) ) { + $out = $this->getOutput(); + + $out->addHTML( Html::openElement( 'div', [ 'class' => 'error' ] ) ); + $out->addWikiMsg( + 'pt-movepage-blockers', + $this->getLanguage()->formatNum( count( $errors ) ) + ); + $s = ''; + foreach ( $errors as $error ) { + $s .= '* ' . wfMessage( ...$error )->plain() . "\n"; + } + TranslateUtils::addWikiTextAsInterface( $out, $s ); + $out->addHTML( '</div>' ); + } + } + + /** + * The query form. + * + * @param array $err Unused. + * @param bool $isPermError Unused. + */ + public function showForm( $err, $isPermError = false ) { + $this->getOutput()->addWikiMsg( 'pt-movepage-intro' ); + + $formDescriptor = [ + 'wpOldTitle' => [ + 'type' => 'text', + 'name' => 'wpOldTitle', + 'label' => $this->msg( 'pt-movepage-current' )->text(), + 'size' => 30, + 'default' => $this->oldText, + 'readonly' => true, + ], + 'wpNewTitle' => [ + 'type' => 'text', + 'name' => 'wpNewTitle', + 'label' => $this->msg( 'pt-movepage-new' )->text(), + 'size' => 30, + 'default' => $this->newText, + ], + 'reason' => [ + 'type' => 'text', + 'name' => 'reason', + 'label' => $this->msg( 'pt-movepage-reason' )->text(), + 'size' => 45, + 'default' => $this->reason, + ], + 'subpages' => [ + 'type' => 'check', + 'name' => 'subpages', + 'id' => 'mw-subpages', + 'label' => $this->msg( 'pt-movepage-subpages' )->text(), + 'default' => $this->moveSubpages, + ] + ]; + + $htmlForm = HTMLForm::factory( 'ooui', $formDescriptor, $this->getContext() ); + $htmlForm + ->addHiddenField( 'wpEditToken', $this->getUser()->getEditToken() ) + ->setMethod( 'post' ) + ->setAction( $this->getPageTitle( $this->oldText )->getLocalURL() ) + ->setSubmitName( 'subaction' ) + ->setSubmitTextMsg( 'pt-movepage-action-check' ) + ->setWrapperLegendMsg( 'pt-movepage-legend' ) + ->prepareForm() + ->displayForm( false ); + } + + /** + * Shortcut for keeping the code at least a bit readable. Adds label and + * input into $form array. + * + * @param string[] &$form Array where input element and label is appended. + * @param string $label Label text. + * @param string $name Name attribute. + * @param bool|int $size Size attribute of the input element. Default false. + * @param bool|string $text Text of the value attribute. Default false. + * @param array $attribs Extra attributes. Default empty array. + */ + protected function addInputLabel( &$form, $label, $name, $size = false, $text = false, + array $attribs = [] + ) { + $br = Html::element( 'br' ); + list( $label, $input ) = Xml::inputLabelSep( + $label, + $name, + $name, + $size, + $text, + $attribs + ); + $form[] = $label . $br; + $form[] = $input . $br; + } + + /** + * The second form, which still allows changing some things. + * Lists all the action which would take place. + */ + protected function showConfirmation() { + $out = $this->getOutput(); + + $out->addWikiMsg( 'pt-movepage-intro' ); + + $base = $this->oldTitle->getPrefixedText(); + $target = $this->newTitle; + $count = 0; + + $types = [ + 'pt-movepage-list-pages' => [ $this->oldTitle ], + 'pt-movepage-list-translation' => $this->getTranslationPages(), + 'pt-movepage-list-section' => $this->getSectionPages(), + 'pt-movepage-list-translatable' => $this->getTranslatableSubpages(), + 'pt-movepage-list-other' => $this->getNormalSubpages(), + ]; + + foreach ( $types as $type => $pages ) { + $out->wrapWikiMsg( '=== $1 ===', [ $type, count( $pages ) ] ); + if ( $type === 'pt-movepage-list-translatable' ) { + $out->addWikiMsg( 'pt-movepage-list-translatable-note' ); + } + + $lines = []; + foreach ( $pages as $old ) { + $toBeMoved = true; + + // These pages need specific checks + if ( $type === 'pt-movepage-list-other' ) { + $toBeMoved = $this->moveSubpages; + } + + if ( $type === 'pt-movepage-list-translatable' ) { + $toBeMoved = false; + } + + if ( $toBeMoved ) { + $count++; + } + + $lines[] = $this->getChangeLine( $base, $old, $target, $toBeMoved ); + } + + TranslateUtils::addWikiTextAsInterface( $out, implode( "\n", $lines ) ); + } + + TranslateUtils::addWikiTextAsInterface( $out, "----\n" ); + $out->addWikiMsg( 'pt-movepage-list-count', $this->getLanguage()->formatNum( $count ) ); + + $br = Html::element( 'br' ); + $readonly = [ 'readonly' => 'readonly' ]; + $subaction = [ 'name' => 'subaction' ]; + $formParams = [ + 'method' => 'post', + 'action' => $this->getPageTitle( $this->oldText )->getLocalURL() + ]; + + $form = []; + $form[] = Xml::fieldset( $this->msg( 'pt-movepage-legend' )->text() ); + $form[] = Html::openElement( 'form', $formParams ); + $form[] = Html::hidden( 'wpEditToken', $this->getUser()->getEditToken() ); + $this->addInputLabel( + $form, + $this->msg( 'pt-movepage-current' )->text(), + 'wpOldTitle', + 30, + $this->oldText, + $readonly + ); + $this->addInputLabel( + $form, + $this->msg( 'pt-movepage-new' )->text(), + 'wpNewTitle', + 30, + $this->newText, + $readonly + ); + $this->addInputLabel( + $form, + $this->msg( 'pt-movepage-reason' )->text(), + 'reason', + 60, + $this->reason + ); + $form[] = Html::hidden( 'subpages', $this->moveSubpages ); + $form[] = Xml::checkLabel( + $this->msg( 'pt-movepage-subpages' )->text(), + 'subpagesFake', + 'mw-subpages', + $this->moveSubpages, + $readonly + ) . $br; + $form[] = Xml::submitButton( $this->msg( 'pt-movepage-action-perform' )->text(), $subaction ); + $form[] = Xml::submitButton( $this->msg( 'pt-movepage-action-other' )->text(), $subaction ); + $form[] = Xml::closeElement( 'form' ); + $form[] = Xml::closeElement( 'fieldset' ); + $out->addHTML( implode( "\n", $form ) ); + } + + /** + * @param string $base + * @param Title $old + * @param Title $target + * @param bool $enabled + * @return string + */ + protected function getChangeLine( $base, Title $old, Title $target, $enabled = true ) { + $to = $this->newPageTitle( $base, $old, $target ); + + if ( $enabled ) { + return '* ' . $old->getPrefixedText() . ' → ' . $to; + } else { + return '* ' . $old->getPrefixedText(); + } + } + + protected function performAction() { + $target = $this->newTitle; + $base = $this->oldTitle->getPrefixedText(); + + $moves = []; + $moves[$base] = $target->getPrefixedText(); + + foreach ( $this->getTranslationPages() as $from ) { + $to = $this->newPageTitle( $base, $from, $target ); + $moves[$from->getPrefixedText()] = $to->getPrefixedText(); + } + + foreach ( $this->getSectionPages() as $from ) { + $to = $this->newPageTitle( $base, $from, $target ); + $moves[$from->getPrefixedText()] = $to->getPrefixedText(); + } + + if ( $this->moveSubpages ) { + $subpages = $this->getNormalSubpages(); + foreach ( $subpages as $from ) { + $to = $this->newPageTitle( $base, $from, $target ); + $moves[$from->getPrefixedText()] = $to->getPrefixedText(); + } + } + + $summary = $this->msg( 'pt-movepage-logreason', $base )->inContentLanguage()->text(); + $job = TranslatablePageMoveJob::newJob( + $this->oldTitle, $this->newTitle, $moves, $summary, $this->getUser() + ); + + JobQueueGroup::singleton()->push( $job ); + + $this->getOutput()->addWikiMsg( 'pt-movepage-started' ); + } + + protected function checkMoveBlockers() { + $blockers = []; + + $target = $this->newTitle; + + if ( !$target ) { + $blockers[] = [ 'pt-movepage-block-base-invalid' ]; + + return $blockers; + } + + if ( $target->inNamespaces( NS_MEDIAWIKI, NS_TRANSLATIONS ) ) { + $blockers[] = [ 'immobile-target-namespace', $target->getNsText() ]; + + return $blockers; + } + + $base = $this->oldTitle->getPrefixedText(); + + if ( $target->exists() ) { + $blockers[] = [ 'pt-movepage-block-base-exists', $target->getPrefixedText() ]; + } else { + $errors = $this->oldTitle->isValidMoveOperation( $target, true, $this->reason ); + if ( is_array( $errors ) ) { + $blockers = array_merge( $blockers, $errors ); + } + } + + // Don't spam the same errors for all pages if base page fails + if ( $blockers ) { + return $blockers; + } + + // Collect all the old and new titles for checcks + $titles = []; + + $pages = $this->getTranslationPages(); + foreach ( $pages as $old ) { + $titles['tp'][] = [ $old, $this->newPageTitle( $base, $old, $target ) ]; + } + + $pages = $this->getSectionPages(); + foreach ( $pages as $old ) { + $titles['section'][] = [ $old, $this->newPageTitle( $base, $old, $target ) ]; + } + + $subpages = $this->moveSubpages ? $this->getNormalSubpages() : []; + foreach ( $subpages as $old ) { + $titles['subpage'][] = [ $old, $this->newPageTitle( $base, $old, $target ) ]; + } + + // Check that all new titles are valid + $lb = new LinkBatch(); + foreach ( $titles as $type => $list ) { + // Give grep a chance to find the usages: + // pt-movepage-block-tp-invalid, pt-movepage-block-section-invalid, + // pt-movepage-block-subpage-invalid + foreach ( $list as $pair ) { + list( $old, $new ) = $pair; + if ( $new === null ) { + $blockers[] = [ + "pt-movepage-block-$type-invalid", + $old->getPrefixedText() + ]; + continue; + } + $lb->addObj( $old ); + $lb->addObj( $new ); + } + } + + if ( $blockers ) { + return $blockers; + } + + // Check that there are no move blockers + $lb->execute(); + foreach ( $titles as $type => $list ) { + // Give grep a chance to find the usages: + // pt-movepage-block-tp-exists, pt-movepage-block-section-exists, + // pt-movepage-block-subpage-exists + foreach ( $list as $pair ) { + list( $old, $new ) = $pair; + if ( $new->exists() ) { + $blockers[] = [ + "pt-movepage-block-$type-exists", + $old->getPrefixedText(), + $new->getPrefixedText() + ]; + } else { + /* This method has terrible performance: + * - 2 queries by core + * - 3 queries by lqt + * - and no obvious way to preload the data! */ + $errors = $old->isValidMoveOperation( $target, false ); + if ( is_array( $errors ) ) { + $blockers = array_merge( $blockers, $errors ); + } + + /* Because of the above, check only one of the possibly thousands + * of section pages and assume rest are fine. */ + if ( $type === 'section' ) { + break; + } + } + } + } + + return $blockers; + } + + /** + * Makes old title into a new title by replacing $base part of old title + * with $target. + * @param string $base Title::getPrefixedText() of the base page. + * @param Title $old The title to convert. + * @param Title $target The target title for the base page. + * @return Title + */ + protected function newPageTitle( $base, Title $old, Title $target ) { + $search = preg_quote( $base, '~' ); + + if ( $old->inNamespace( NS_TRANSLATIONS ) ) { + $new = $old->getText(); + $new = preg_replace( "~^$search~", $target->getPrefixedText(), $new, 1 ); + + return Title::makeTitleSafe( NS_TRANSLATIONS, $new ); + } else { + $new = $old->getPrefixedText(); + $new = preg_replace( "~^$search~", $target->getPrefixedText(), $new, 1 ); + + return Title::newFromText( $new ); + } + } + + /** + * Returns all section pages, including those which are currently not active. + * @return Title[] + */ + protected function getSectionPages() { + if ( !isset( $this->sectionPages ) ) { + $this->sectionPages = $this->page->getTranslationUnitPages( 'all' ); + } + + return $this->sectionPages; + } + + /** + * Returns only translation subpages. + * @return Array of titles. + */ + protected function getTranslationPages() { + if ( !isset( $this->translationPages ) ) { + $this->translationPages = $this->page->getTranslationPages(); + } + + return $this->translationPages; + } + + /** + * Returns all subpages, if the namespace has them enabled. + * @return mixed TitleArray, or empty array if this page's namespace doesn't allow subpages + */ + protected function getSubpages() { + return $this->page->getTitle()->getSubpages(); + } + + private function getNormalSubpages() { + return array_filter( + iterator_to_array( $this->getSubpages() ), + function ( $page ) { + return !( + TranslatablePage::isTranslationPage( $page ) || + TranslatablePage::isSourcePage( $page ) + ); + } + ); + } + + private function getTranslatableSubpages() { + return array_filter( + iterator_to_array( $this->getSubpages() ), + function ( $page ) { + return TranslatablePage::isSourcePage( $page ); + } + ); + } +} diff --git a/www/wiki/extensions/Translate/tag/TPException.php b/www/wiki/extensions/Translate/tag/TPException.php new file mode 100644 index 00000000..b01682d6 --- /dev/null +++ b/www/wiki/extensions/Translate/tag/TPException.php @@ -0,0 +1,36 @@ +<?php +/** + * Translatable page parse exception. + * + * @file + * @author Niklas Laxström + * @copyright Copyright © 2009-2013 Niklas Laxström + * @license GPL-2.0-or-later + */ + +/** + * Class to signal syntax errors in translatable pages. + * + * @ingroup PageTranslation + */ +class TPException extends MWException { + protected $msg; + + /** + * @todo Pass around Messages when Status class doesn't suck + * @param array $msg Message key with parameters + */ + public function __construct( array $msg ) { + $this->msg = $msg; + // Using ->plain() instead of ->text() due to bug T58226 + $wikitext = call_user_func_array( 'wfMessage', $msg )->plain(); + parent::__construct( $wikitext ); + } + + /** + * @return array + */ + public function getMsg() { + return $this->msg; + } +} diff --git a/www/wiki/extensions/Translate/tag/TPParse.php b/www/wiki/extensions/Translate/tag/TPParse.php new file mode 100644 index 00000000..9e1b32bc --- /dev/null +++ b/www/wiki/extensions/Translate/tag/TPParse.php @@ -0,0 +1,250 @@ +<?php +/** + * Helper code for TranslatablePage. + * + * @file + * @author Niklas Laxström + * @copyright Copyright © 2009-2013 Niklas Laxström + * @license GPL-2.0-or-later + */ + +/** + * This class represents the results of parsed source page, that is, the + * extracted sections and a template. + * + * @ingroup PageTranslation + */ +class TPParse { + /** @var Title Title of the page. */ + protected $title; + + /** @var TPSection[] Parsed sections indexed with placeholder. + * @todo Encapsulate + */ + public $sections = []; + /** @var string Page source with content replaced with placeholders. + * @todo Encapsulate + */ + public $template = null; + /** + * @var null|array Sections saved in the database. array( string => TPSection, ... ) + */ + protected $dbSections = null; + + /// Constructor + public function __construct( Title $title ) { + $this->title = $title; + } + + /** + * Returns the number of sections in this page. + * @return int + */ + public function countSections() { + return count( $this->sections ); + } + + /** + * Returns the page template where translatable content is replaced with + * placeholders. + * @return string + */ + public function getTemplate() { + return $this->template; + } + + /** + * Returns the page template where the ugly placeholders are replaced with + * section markers. Sections which previously had no number will get one + * assigned now. + * @return string + */ + public function getTemplatePretty() { + $text = $this->template; + $sections = $this->getSectionsForSave(); + foreach ( $sections as $ph => $s ) { + $text = str_replace( $ph, "<!--T:{$s->id}-->", $text ); + } + + return $text; + } + + /** + * Gets the sections and assigns section id for new sections + * @param int $highest The largest used integer id (Since 2012-08-02) + * @return TPSection[] array( string => TPSection, ... ) + */ + public function getSectionsForSave( $highest = 0 ) { + $this->loadFromDatabase(); + + $sections = $this->sections; + foreach ( array_keys( $this->dbSections ) as $key ) { + $highest = max( $highest, (int)$key ); + } + + foreach ( $sections as $_ ) { + $highest = max( $highest, (int)$_->id ); + } + + foreach ( $sections as $s ) { + $s->type = 'old'; + + if ( $s->id === -1 ) { + $s->type = 'new'; + $s->id = ++$highest; + } else { + if ( isset( $this->dbSections[$s->id] ) ) { + $storedText = $this->dbSections[$s->id]->text; + if ( $s->text !== $storedText ) { + $s->type = 'changed'; + $s->oldText = $storedText; + } + } + } + } + + return $sections; + } + + /** + * Returns list of deleted sections. + * @return TPSection[] List of sections indexed by id. array( string => TPsection, ... ) + */ + public function getDeletedSections() { + $sections = $this->getSectionsForSave(); + $deleted = $this->dbSections; + + foreach ( $sections as $s ) { + if ( isset( $deleted[$s->id] ) ) { + unset( $deleted[$s->id] ); + } + } + + return $deleted; + } + + /** + * Load section saved in the database. Populates dbSections. + */ + protected function loadFromDatabase() { + if ( $this->dbSections !== null ) { + return; + } + + $this->dbSections = []; + + $db = TranslateUtils::getSafeReadDB(); + $tables = 'translate_sections'; + $vars = [ 'trs_key', 'trs_text' ]; + $conds = [ 'trs_page' => $this->title->getArticleID() ]; + + $res = $db->select( $tables, $vars, $conds, __METHOD__ ); + foreach ( $res as $r ) { + $section = new TPSection; + $section->id = $r->trs_key; + $section->text = $r->trs_text; + $section->type = 'db'; + $this->dbSections[$r->trs_key] = $section; + } + } + + /** + * Returns the source page with translation section mark-up added. + * + * @return string Wikitext. + */ + public function getSourcePageText() { + $text = $this->template; + + foreach ( $this->sections as $ph => $s ) { + $text = str_replace( $ph, $s->getMarkedText(), $text ); + } + + return $text; + } + + /** + * Returns translation page with all possible translations replaced in, ugly + * translation tags removed and outdated translation marked with a class + * mw-translate-fuzzy. + * + * @param MessageCollection $collection Collection that holds translated messages. + * @param bool $showOutdated Whether to show outdated sections, wrapped in a HTML class. + * @return string Whole page as wikitext. + */ + public function getTranslationPageText( $collection, $showOutdated = false ) { + $text = $this->template; // The source + + // For finding the messages + $prefix = $this->title->getPrefixedDBkey() . '/'; + + if ( $collection instanceof MessageCollection ) { + $collection->loadTranslations(); + if ( $showOutdated ) { + $collection->filter( 'hastranslation', false ); + } else { + $collection->filter( 'translated', false ); + } + } + + foreach ( $this->sections as $ph => $s ) { + $sectiontext = null; + + if ( isset( $collection[$prefix . $s->id] ) ) { + /** @var TMessage $msg */ + $msg = $collection[$prefix . $s->id]; + /** @var string|null */ + $sectiontext = $msg->translation(); + + // If translation is fuzzy, $sectiontext must be a string + if ( $msg->hasTag( 'fuzzy' ) ) { + // We do not ever want to show explicit fuzzy marks in the rendered pages + $sectiontext = str_replace( TRANSLATE_FUZZY, '', $sectiontext ); + + if ( $s->isInline() ) { + $sectiontext = "<span class=\"mw-translate-fuzzy\">$sectiontext</span>"; + } else { + // We add new lines around the text to avoid disturbing any mark-up that + // has special handling on line start, such as lists. + $sectiontext = "<div class=\"mw-translate-fuzzy\">\n$sectiontext\n</div>"; + } + } + } + + // Use the original text if no translation is available. + + // For the source language, this will actually be the source, which + // contains variable declarations (tvar) instead of variables ($1). + // The getTextWithVariables will convert declarations to normal variables + // for us so that the variable substitutions below will also work + // for the source language. + if ( $sectiontext === null || $sectiontext === $s->getText() ) { + $sectiontext = $s->getTextWithVariables(); + } + + // Substitute variables into section text and substitute text into document + $sectiontext = strtr( $sectiontext, $s->getVariables() ); + $text = str_replace( $ph, $sectiontext, $text ); + } + + $nph = []; + $text = TranslatablePage::armourNowiki( $nph, $text ); + + // Remove translation markup from the template to produce final text + $cb = [ __CLASS__, 'replaceTagCb' ]; + $text = preg_replace_callback( '~(<translate>)(.*)(</translate>)~sU', $cb, $text ); + $text = TranslatablePage::unArmourNowiki( $nph, $text ); + + return $text; + } + + /** + * Chops of trailing or preceeding whitespace intelligently to avoid + * build up of unintented whitespace. + * @param string[] $matches + * @return string + */ + protected static function replaceTagCb( $matches ) { + return preg_replace( '~^\n|\n\z~', '', $matches[2] ); + } +} diff --git a/www/wiki/extensions/Translate/tag/TPSection.php b/www/wiki/extensions/Translate/tag/TPSection.php new file mode 100644 index 00000000..e42e2b46 --- /dev/null +++ b/www/wiki/extensions/Translate/tag/TPSection.php @@ -0,0 +1,175 @@ +<?php +/** + * Helper for TPParse. + * + * @file + * @author Niklas Laxström + * @license GPL-2.0-or-later + */ + +/** + * This class represents one individual section in translatable page. + * + * @ingroup PageTranslation + */ +class TPSection { + /** + * @var string Section name + */ + public $id; + + /** + * @var string|null New name of the section, that will be saved to database. + */ + public $name = null; + + /** + * @var string Section text. + */ + public $text; + + /** + * @var string Is this new, existing, changed or deleted section. + */ + public $type; + + /** + * @var string|null Text of previous version of this section. + */ + public $oldText = null; + + /** + * @var bool Whether this section is inline section. + * E.g. "Something <translate>foo</translate> bar". + */ + protected $inline = false; + + /** + * @var int Version number for the serialization. + */ + private $version = 1; + + /** + * @var string[] List of properties to serialize. + */ + private static $properties = [ 'version', 'id', 'name', 'text', 'type', 'oldText', 'inline' ]; + + public function setIsInline( $value ) { + $this->inline = (bool)$value; + } + + public function isInline() { + return $this->inline; + } + + /** + * Returns section text unmodified. + * @return string Wikitext. + */ + public function getText() { + return $this->text; + } + + /** + * Returns the text with tvars replaces with placeholders. + * @return string Wikitext. + * @since 2014.07 + */ + public function getTextWithVariables() { + $re = '~<tvar\|([^>]+)>(.*?)</>~us'; + + return preg_replace( $re, '$\1', $this->text ); + } + + /** + * Returns section text with variables replaced. + * @return string Wikitext. + */ + public function getTextForTrans() { + $re = '~<tvar\|([^>]+)>(.*?)</>~us'; + + return preg_replace( $re, '\2', $this->text ); + } + + /** + * Returns the section text with updated or added section marker. + * + * @return string Wikitext. + */ + public function getMarkedText() { + $id = $this->name !== null ? $this->name : $this->id; + $header = "<!--T:{$id}-->"; + $re = '~^(=+.*?=+\s*?$)~m'; + $rep = "\\1 $header"; + $count = 0; + + $text = preg_replace( $re, $rep, $this->text, 1, $count ); + + if ( $count === 0 ) { + if ( $this->inline ) { + $text = $header . ' ' . $this->text; + } else { + $text = $header . "\n" . $this->text; + } + } + + return $text; + } + + /** + * Returns oldtext, or current text if not available. + * @return string Wikitext. + */ + public function getOldText() { + return $this->oldText !== null ? $this->oldText : $this->text; + } + + /** + * Returns array of variables defined on this section. + * @return array ( string => string ) Values indexed with keys which are + * prefixed with a dollar sign. + */ + public function getVariables() { + $re = '~<tvar\|([^>]+)>(.*?)</>~us'; + $matches = []; + preg_match_all( $re, $this->text, $matches, PREG_SET_ORDER ); + $vars = []; + + foreach ( $matches as $m ) { + $vars['$' . $m[1]] = $m[2]; + } + + return $vars; + } + + /** + * Serialize this object to a PHP array. + * @return array + * @since 2018.07 + */ + public function serializeToArray() { + $data = []; + foreach ( self::$properties as $index => $property ) { + // Because this is used for the JobQueue, use a list + // instead of an array to save space. + $data[ $index ] = $this->$property; + } + + return $data; + } + + /** + * Construct an object from previously serialized array. + * @param array $data + * @return self + * @since 2018.07 + */ + public static function unserializeFromArray( $data ) { + $section = new self; + foreach ( self::$properties as $index => $property ) { + $section->$property = $data[ $index ]; + } + + return $section; + } +} diff --git a/www/wiki/extensions/Translate/tag/TranslatablePage.php b/www/wiki/extensions/Translate/tag/TranslatablePage.php new file mode 100644 index 00000000..51a32db9 --- /dev/null +++ b/www/wiki/extensions/Translate/tag/TranslatablePage.php @@ -0,0 +1,900 @@ +<?php +/** + * Translatable page model. + * + * @file + * @author Niklas Laxström + * @license GPL-2.0-or-later + */ + +use Wikimedia\Rdbms\Database; + +/** + * Class to parse translatable wiki pages. + * + * @ingroup PageTranslation + */ +class TranslatablePage { + /** + * Title of the page. + */ + protected $title; + + /** + * Text contents of the page. + */ + protected $text; + + /** + * Revision of the page, if applicaple. + * + * @var int + */ + protected $revision; + + /** + * From which source this object was constructed. + * Can be: text, revision, title + */ + protected $source; + + /** + * Whether the page contents is already loaded. + */ + protected $init = false; + + /** + * Name of the section which contains the translated page title. + */ + protected $displayTitle = 'Page display title'; + + /** + * Whether the title should be translated + * @var bool + */ + protected $pageDisplayTitle; + + protected $cachedParse; + + /** + * @param Title $title Title object for the page + */ + protected function __construct( Title $title ) { + $this->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 = '~(<translate>)(.*?)(</translate>)~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, '<translate>' ) !== 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, '<translate>' ) !== false ) { + throw new TPException( [ 'pt-parse-open', $prettyTemplate ] ); + } elseif ( strpos( $text, '</translate>' ) !== 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( '~<translate>\n?~s', '', $text ); + $text = preg_replace( '~\n?</translate>~s', '', $text ); + // Mirroring what TPSection::getTextForTrans does + $text = preg_replace( '~<tvar\|([^>]+)>(.*?)</>~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 = '~(<nowiki>)(.*?)(</nowiki>)~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 \<translate> 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 \<translate> 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 = '~<!--T:(.*?)-->~'; + $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 = '~^<!--T:(.*?)-->( |\n)~'; // Normal sections + $rer2 = '~\s*<!--T:(.*?)-->$~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; + } +} diff --git a/www/wiki/extensions/Translate/tag/TranslatablePageMoveJob.php b/www/wiki/extensions/Translate/tag/TranslatablePageMoveJob.php new file mode 100644 index 00000000..27f4b48d --- /dev/null +++ b/www/wiki/extensions/Translate/tag/TranslatablePageMoveJob.php @@ -0,0 +1,168 @@ +<?php +/** + * Contains class with job for moving translation pages. + * + * @file + * @author Niklas Laxström + * @license GPL-2.0-or-later + */ + +/** + * Contains class with job for moving translation pages. Used together with + * SpecialPageTranslationMovePage class. + * + * @ingroup PageTranslation JobQueue + */ +class TranslatablePageMoveJob extends Job { + + /** + * @param Title $source + * @param Title $target + * @param array $moves should include base-source and base-target + * @param string $summary + * @param User $performer + * @return TranslatablePageMoveJob + */ + public static function newJob( + Title $source, Title $target, array $moves, $summary, User $performer + ) { + $params = [ + 'source' => $source->getPrefixedText(), + 'target' => $target->getPrefixedText(), + 'moves' => $moves, + 'summary' => $summary, + 'performer' => $performer->getName(), + ]; + + $self = new self( $target, $params ); + $self->lock( array_keys( $moves ) ); + $self->lock( array_values( $moves ) ); + + return $self; + } + + public function __construct( $title, $params = [] ) { + parent::__construct( __CLASS__, $title, $params ); + } + + public function run() { + $sourceTitle = Title::newFromText( $this->params['source'] ); + $targetTitle = Title::newFromText( $this->params['target'] ); + $sourcePage = TranslatablePage::newFromTitle( $sourceTitle ); + $targetPage = TranslatablePage::newFromTitle( $targetTitle ); + + $this->doMoves(); + + $this->moveMetadata( + $sourcePage->getMessageGroupId(), + $targetPage->getMessageGroupId() + ); + + $entry = new ManualLogEntry( 'pagetranslation', 'moveok' ); + $entry->setPerformer( User::newFromName( $this->params['performer'] ) ); + $entry->setParameters( [ 'target' => $this->params['target'] ] ); + $entry->setTarget( $sourceTitle ); + $logid = $entry->insert(); + $entry->publish( $logid ); + + // Re-render the pages to get everything in sync + MessageGroups::singleton()->recache(); + // Update message index now so that, when after this job the MoveTranslationUnits hook + // runs in deferred updates, it will not run MessageIndexRebuildJob (T175834). + MessageIndex::singleton()->rebuild(); + + $job = TranslationsUpdateJob::newFromPage( $targetPage ); + JobQueueGroup::singleton()->push( $job ); + + return true; + } + + protected function doMoves() { + $fuzzybot = FuzzyBot::getUser(); + $performer = User::newFromName( $this->params['performer'] ); + + PageTranslationHooks::$allowTargetEdit = true; + + foreach ( $this->params['moves'] as $source => $target ) { + $sourceTitle = Title::newFromText( $source ); + $targetTitle = Title::newFromText( $target ); + + if ( $source === $this->params['source'] ) { + $user = $performer; + } else { + $user = $fuzzybot; + } + + $mover = new MovePage( $sourceTitle, $targetTitle ); + $status = $mover->move( $user, $this->params['summary'], false ); + if ( !$status->isOK() ) { + $entry = new ManualLogEntry( 'pagetranslation', 'movenok' ); + $entry->setPerformer( $performer ); + $entry->setTarget( $sourceTitle ); + $entry->setParameters( [ + 'target' => $target, + 'error' => $status->getErrorsArray(), + ] ); + $logid = $entry->insert(); + $entry->publish( $logid ); + } + + $this->unlock( [ $source, $target ] ); + } + + PageTranslationHooks::$allowTargetEdit = false; + } + + protected function moveMetadata( $oldGroupId, $newGroupId ) { + $types = [ 'prioritylangs', 'priorityforce', 'priorityreason' ]; + + TranslateMetadata::preloadGroups( [ $oldGroupId, $newGroupId ] ); + foreach ( $types as $type ) { + $value = TranslateMetadata::get( $oldGroupId, $type ); + if ( $value !== false ) { + TranslateMetadata::set( $oldGroupId, $type, false ); + TranslateMetadata::set( $newGroupId, $type, $value ); + } + } + + // Make the changes in aggregate groups metadata, if present in any of them. + $aggregateGroups = MessageGroups::getGroupsByType( AggregateMessageGroup::class ); + TranslateMetadata::preloadGroups( array_keys( $aggregateGroups ) ); + + foreach ( $aggregateGroups as $id => $group ) { + $subgroups = TranslateMetadata::get( $id, 'subgroups' ); + if ( $subgroups === false ) { + continue; + } + + $subgroups = explode( ',', $subgroups ); + $subgroups = array_flip( $subgroups ); + if ( isset( $subgroups[$oldGroupId] ) ) { + $subgroups[$newGroupId] = $subgroups[$oldGroupId]; + unset( $subgroups[$oldGroupId] ); + $subgroups = array_flip( $subgroups ); + TranslateMetadata::set( + $group->getId(), + 'subgroups', + implode( ',', $subgroups ) + ); + } + } + } + + private function lock( array $titles ) { + $cache = wfGetCache( CACHE_ANYTHING ); + $data = []; + foreach ( $titles as $title ) { + $data[wfMemcKey( 'pt-lock', sha1( $title ) )] = 'locked'; + } + $cache->setMulti( $data ); + } + + private function unlock( array $titles ) { + $cache = wfGetCache( CACHE_ANYTHING ); + foreach ( $titles as $title ) { + $cache->delete( wfMemcKey( 'pt-lock', sha1( $title ) ) ); + } + } +} diff --git a/www/wiki/extensions/Translate/tag/TranslateDeleteJob.php b/www/wiki/extensions/Translate/tag/TranslateDeleteJob.php new file mode 100644 index 00000000..a59f2c6d --- /dev/null +++ b/www/wiki/extensions/Translate/tag/TranslateDeleteJob.php @@ -0,0 +1,172 @@ +<?php +/** + * Contains class with job for deleting translatable and translation pages. + * + * @file + * @author Niklas Laxström + * @copyright Copyright © 2008-2013, Niklas Laxström + * @license GPL-2.0-or-later + */ + +/** + * Contains class with job for deleting translatable and translation pages. + * + * @ingroup PageTranslation JobQueue + */ +class TranslateDeleteJob extends Job { + /** + * @param Title $target + * @param string $base + * @param string $full + * @param User $performer + * @param string $reason + * @return self + */ + public static function newJob( Title $target, $base, $full, /*User*/ $performer, $reason ) { + $job = new self( $target ); + $job->setUser( FuzzyBot::getUser() ); + $job->setFull( $full ); + $job->setBase( $base ); + $msg = $job->getFull() ? 'pt-deletepage-full-logreason' : 'pt-deletepage-lang-logreason'; + $job->setSummary( wfMessage( $msg, $base )->inContentLanguage()->text() ); + $job->setPerformer( $performer ); + $job->setReason( $reason ); + + return $job; + } + + /** + * @param Title $title + * @param array $params + */ + public function __construct( $title, $params = [] ) { + parent::__construct( __CLASS__, $title, $params ); + } + + public function run() { + // Initialization + $title = $this->title; + // Other stuff + $user = $this->getUser(); + $summary = $this->getSummary(); + $base = $this->getBase(); + $doer = User::newFromName( $this->getPerformer() ); + $reason = $this->getReason(); + + PageTranslationHooks::$allowTargetEdit = true; + PageTranslationHooks::$jobQueueRunning = true; + + $error = ''; + $wikipage = new WikiPage( $title ); + $status = $wikipage->doDeleteArticleReal( "{$summary}: $reason", false, 0, true, $error, + $user, [], 'delete', true ); + if ( !$status->isGood() ) { + $params = [ + 'target' => $base, + 'errors' => $status->getErrorsArray(), + ]; + + $type = $this->getFull() ? 'deletefnok' : 'deletelnok'; + $entry = new ManualLogEntry( 'pagetranslation', $type ); + $entry->setPerformer( $doer ); + $entry->setComment( $reason ); + $entry->setTarget( $title ); + $entry->setParameters( $params ); + $logid = $entry->insert(); + $entry->publish( $logid ); + } + + PageTranslationHooks::$allowTargetEdit = false; + + $cache = wfGetCache( CACHE_DB ); + $pages = (array)$cache->get( wfMemcKey( 'pt-base', $base ) ); + $lastitem = array_pop( $pages ); + if ( $title->getPrefixedText() === $lastitem ) { + $cache->delete( wfMemcKey( 'pt-base', $base ) ); + + $type = $this->getFull() ? 'deletefok' : 'deletelok'; + $entry = new ManualLogEntry( 'pagetranslation', $type ); + $entry->setPerformer( $doer ); + $entry->setComment( $reason ); + $entry->setTarget( Title::newFromText( $base ) ); + $logid = $entry->insert(); + $entry->publish( $logid ); + + $tpage = TranslatablePage::newFromTitle( $title ); + $tpage->getTranslationPercentages( true ); + foreach ( $tpage->getTranslationPages() as $page ) { + $page->invalidateCache(); + } + $title->invalidateCache(); + PageTranslationHooks::$jobQueueRunning = false; + } + + return true; + } + + public function setSummary( $summary ) { + $this->params['summary'] = $summary; + } + + public function getSummary() { + return $this->params['summary']; + } + + public function setReason( $reason ) { + $this->params['reason'] = $reason; + } + + public function getReason() { + return $this->params['reason']; + } + + public function setFull( $full ) { + $this->params['full'] = $full; + } + + public function getFull() { + return $this->params['full']; + } + + /** + * @param User|string $performer + */ + public function setPerformer( $performer ) { + if ( is_object( $performer ) ) { + $this->params['performer'] = $performer->getName(); + } else { + $this->params['performer'] = $performer; + } + } + + public function getPerformer() { + return $this->params['performer']; + } + + /** + * @param User|string $user + */ + public function setUser( $user ) { + if ( is_object( $user ) ) { + $this->params['user'] = $user->getName(); + } else { + $this->params['user'] = $user; + } + } + + public function setBase( $base ) { + $this->params['base'] = $base; + } + + public function getBase() { + return $this->params['base']; + } + + /** + * Get a user object for doing edits. + * @return User + */ + public function getUser() { + return User::newFromName( $this->params['user'], false ); + } +} diff --git a/www/wiki/extensions/Translate/tag/TranslateMoveJob.php b/www/wiki/extensions/Translate/tag/TranslateMoveJob.php new file mode 100644 index 00000000..a1771b1d --- /dev/null +++ b/www/wiki/extensions/Translate/tag/TranslateMoveJob.php @@ -0,0 +1,219 @@ +<?php +/** + * Contains class with job for moving translation pages. + * + * @file + * @author Niklas Laxström + * @copyright Copyright © 2008-2010, Niklas Laxström + * @license GPL-2.0-or-later + */ + +/** + * Contains class with job for moving translation pages. Used together with + * PageTranslationMovePage class. + * + * @ingroup PageTranslation JobQueue + */ +class TranslateMoveJob extends Job { + /** + * @param Title $source + * @param Title $target + * @param array $params should include base-source and base-target + * @param User $performer + * @return self + */ + public static function newJob( Title $source, Title $target, array $params, + /*User*/ $performer + ) { + $job = new self( $source ); + $job->setUser( FuzzyBot::getUser() ); + $job->setTarget( $target->getPrefixedText() ); + $summary = wfMessage( 'pt-movepage-logreason', $params['base-source'] ); + $summary = $summary->inContentLanguage()->text(); + $job->setSummary( $summary ); + $job->setParams( $params ); + $job->setPerformer( $performer ); + $job->lock(); + + return $job; + } + + /** + * @param Title $title + * @param array $params + */ + public function __construct( $title, $params = [] ) { + parent::__construct( __CLASS__, $title, $params ); + } + + public function run() { + // Unfortunately the global is needed until bug is fixed: + // https://phabricator.wikimedia.org/T51086 + // Once MW >= 1.24 is supported, can use MovePage class. + global $wgUser; + + // Initialization + $title = $this->title; + // Other stuff + $user = $this->getUser(); + $summary = $this->getSummary(); + $target = $this->getTarget(); + $base = $this->params['base-source']; + $doer = User::newFromName( $this->getPerformer() ); + + PageTranslationHooks::$allowTargetEdit = true; + PageTranslationHooks::$jobQueueRunning = true; + $oldUser = $wgUser; + $wgUser = $user; + self::forceRedirects( false ); + + // Don't check perms, don't leave a redirect + $ok = $title->moveTo( $target, false, $summary, false ); + if ( !$ok ) { + $params = [ + 'target' => $target->getPrefixedText(), + 'error' => $ok, + ]; + + $entry = new ManualLogEntry( 'pagetranslation', 'movenok' ); + $entry->setPerformer( $doer ); + $entry->setTarget( $title ); + $entry->setParameters( $params ); + $logid = $entry->insert(); + $entry->publish( $logid ); + } + + self::forceRedirects( true ); + PageTranslationHooks::$allowTargetEdit = false; + + $this->unlock(); + + $cache = wfGetCache( CACHE_ANYTHING ); + $key = wfMemcKey( 'translate-pt-move', $base ); + + $count = $cache->decr( $key ); + $last = (string)$count === '0'; + + if ( $last ) { + $cache->delete( $key ); + + $params = [ + 'target' => $this->params['base-target'], + ]; + + $entry = new ManualLogEntry( 'pagetranslation', 'moveok' ); + $entry->setPerformer( $doer ); + $entry->setParameters( $params ); + $entry->setTarget( Title::newFromText( $base ) ); + $logid = $entry->insert(); + $entry->publish( $logid ); + + PageTranslationHooks::$jobQueueRunning = false; + } + + $wgUser = $oldUser; + + return true; + } + + public function setSummary( $summary ) { + $this->params['summary'] = $summary; + } + + public function getSummary() { + return $this->params['summary']; + } + + public function setPerformer( $performer ) { + if ( is_object( $performer ) ) { + $this->params['performer'] = $performer->getName(); + } else { + $this->params['performer'] = $performer; + } + } + + public function getPerformer() { + return $this->params['performer']; + } + + /** + * @param Title|string $target + */ + public function setTarget( $target ) { + if ( $target instanceof Title ) { + $this->params['target'] = $target->getPrefixedText(); + } else { + $this->params['target'] = $target; + } + } + + public function getTarget() { + return Title::newFromText( $this->params['target'] ); + } + + public function setUser( $user ) { + if ( is_object( $user ) ) { + $this->params['user'] = $user->getName(); + } else { + $this->params['user'] = $user; + } + } + + /** + * Get a user object for doing edits. + * @return User + */ + public function getUser() { + return User::newFromName( $this->params['user'], false ); + } + + public function setParams( array $params ) { + foreach ( $params as $k => $v ) { + $this->params[$k] = $v; + } + } + + public function lock() { + $cache = wfGetCache( CACHE_ANYTHING ); + $cache->set( wfMemcKey( 'pt-lock', sha1( $this->title->getPrefixedText() ) ), true ); + $cache->set( wfMemcKey( 'pt-lock', sha1( $this->getTarget()->getPrefixedText() ) ), true ); + } + + public function unlock() { + $cache = wfGetCache( CACHE_ANYTHING ); + $cache->delete( wfMemcKey( 'pt-lock', sha1( $this->title->getPrefixedText() ) ) ); + $cache->delete( wfMemcKey( 'pt-lock', sha1( $this->getTarget()->getPrefixedText() ) ) ); + } + + /** + * Adapted from wfSuppressWarnings to allow not leaving redirects. + * @param bool $end + */ + public static function forceRedirects( $end = false ) { + static $suppressCount = 0; + static $originalLevel = null; + + global $wgGroupPermissions; + global $wgUser; + + if ( $end ) { + if ( $suppressCount ) { + --$suppressCount; + if ( !$suppressCount ) { + if ( $originalLevel === null ) { + unset( $wgGroupPermissions['*']['suppressredirect'] ); + } else { + $wgGroupPermissions['*']['suppressredirect'] = $originalLevel; + } + } + } + } else { + if ( !$suppressCount ) { + $originalLevel = $wgGroupPermissions['*']['suppressredirect'] ?? null; + $wgGroupPermissions['*']['suppressredirect'] = true; + } + ++$suppressCount; + } + $wgUser->clearInstanceCache(); + } +} diff --git a/www/wiki/extensions/Translate/tag/TranslateRenderJob.php b/www/wiki/extensions/Translate/tag/TranslateRenderJob.php new file mode 100644 index 00000000..1b6ff517 --- /dev/null +++ b/www/wiki/extensions/Translate/tag/TranslateRenderJob.php @@ -0,0 +1,112 @@ +<?php +/** + * Job for updating translation pages. + * + * @file + * @author Niklas Laxström + * @license GPL-2.0-or-later + */ + +/** + * Job for updating translation pages when translation or template changes. + * + * @ingroup PageTranslation JobQueue + */ +class TranslateRenderJob extends Job { + + /** + * @param Title $target + * @return self + */ + public static function newJob( Title $target ) { + $job = new self( $target ); + $job->setUser( FuzzyBot::getUser() ); + $job->setFlags( EDIT_FORCE_BOT ); + $job->setSummary( wfMessage( 'tpt-render-summary' )->inContentLanguage()->text() ); + + return $job; + } + + /** + * @param Title $title + * @param array $params + */ + public function __construct( $title, $params = [] ) { + parent::__construct( __CLASS__, $title, $params ); + $this->removeDuplicates = true; + } + + public function run() { + global $wgTranslateKeepOutdatedTranslations; + + // Initialization + $title = $this->title; + list( , $code ) = TranslateUtils::figureMessage( $title->getPrefixedText() ); + + // Return the actual translation page... + $page = TranslatablePage::isTranslationPage( $title ); + if ( !$page ) { + throw new MWException( "Cannot render translation page for {$title->getPrefixedText()}!" ); + } + + $group = $page->getMessageGroup(); + $collection = $group->initCollection( $code ); + + $text = $page->getParse()->getTranslationPageText( + $collection, + $wgTranslateKeepOutdatedTranslations + ); + + // Other stuff + $user = $this->getUser(); + $summary = $this->getSummary(); + $flags = $this->getFlags(); + + $page = WikiPage::factory( $title ); + + // @todo FuzzyBot hack + PageTranslationHooks::$allowTargetEdit = true; + $content = ContentHandler::makeContent( $text, $page->getTitle() ); + $page->doEditContent( $content, $summary, $flags, false, $user ); + + PageTranslationHooks::$allowTargetEdit = false; + + return true; + } + + public function setFlags( $flags ) { + $this->params['flags'] = $flags; + } + + public function getFlags() { + return $this->params['flags']; + } + + public function setSummary( $summary ) { + $this->params['summary'] = $summary; + } + + public function getSummary() { + return $this->params['summary']; + } + + /** + * @param User|string $user + */ + public function setUser( $user ) { + if ( $user instanceof User ) { + $this->params['user'] = $user->getName(); + } else { + $this->params['user'] = $user; + } + } + + /** + * Get a user object for doing edits. + * + * @return User + */ + public function getUser() { + return User::newFromName( $this->params['user'], false ); + } +} diff --git a/www/wiki/extensions/Translate/tag/TranslationsUpdateJob.php b/www/wiki/extensions/Translate/tag/TranslationsUpdateJob.php new file mode 100644 index 00000000..f3020f41 --- /dev/null +++ b/www/wiki/extensions/Translate/tag/TranslationsUpdateJob.php @@ -0,0 +1,127 @@ +<?php +/** + * Job for updating translation units and translation pages when + * a translatable page is marked for translation. + * + * @note MessageUpdateJobs from getTranslationUnitJobs() should be run + * before the TranslateRenderJobs are run so that the latest changes can + * take effect on the translation pages. + * + * @since 2016.03 + */ +class TranslationsUpdateJob extends Job { + /** + * @inheritDoc + */ + public function __construct( Title $title, $params = [] ) { + parent::__construct( __CLASS__, $title, $params ); + } + + /** + * Create a job that updates a translation page. + * + * If a list of sections is provided, then the job will also update translation + * unit pages. + * + * @param TranslatablePage $page + * @param TPSection[] $sections + * @return TranslationsUpdateJob + * @since 2018.07 + */ + public static function newFromPage( TranslatablePage $page, array $sections = [] ) { + $params = []; + $params[ 'sections' ] = []; + foreach ( $sections as $section ) { + $params[ 'sections' ][] = $section->serializeToArray(); + } + + return new self( $page->getTitle(), $params ); + } + + public function run() { + $page = TranslatablePage::newFromTitle( $this->title ); + $sections = $this->params[ 'sections' ]; + foreach ( $sections as $index => $section ) { + // Old jobs stored sections as objects because they were serialized and + // unserialized transparently. That is no longer supported, so we + // convert manually to primitive types first (to an PHP array). + if ( is_array( $section ) ) { + $sections[ $index ] = TPSection::unserializeFromArray( $section ); + } + } + + // Units should be updated before the render jobs are run + $unitJobs = self::getTranslationUnitJobs( $page, $sections ); + foreach ( $unitJobs as $job ) { + $job->run(); + } + + // Ensure we are using the latest group definitions. This is needed so + // that in long running scripts we do see the page which was just + // marked for translation. Otherwise getMessageGroup in the next line + // returns null. There is no need to regenerate the global cache. + MessageGroups::singleton()->clearProcessCache(); + // Ensure fresh definitions for MessageIndex and stats + $page->getMessageGroup()->clearCaches(); + + MessageIndex::singleton()->rebuild(); + + // Refresh translations statistics + $id = $page->getMessageGroupId(); + MessageGroupStats::forGroup( $id, MessageGroupStats::FLAG_NO_CACHE ); + + $wikiPage = WikiPage::factory( $page->getTitle() ); + $wikiPage->doPurge(); + + $renderJobs = self::getRenderJobs( $page ); + JobQueueGroup::singleton()->push( $renderJobs ); + return true; + } + + /** + * Creates jobs needed to create or update all translation page definitions. + * @param TranslatablePage $page + * @param TPSection[] $sections + * @return Job[] + * @since 2013-01-28 + */ + public static function getTranslationUnitJobs( TranslatablePage $page, array $sections ) { + $jobs = []; + + $code = $page->getSourceLanguageCode(); + $prefix = $page->getTitle()->getPrefixedText(); + + foreach ( $sections as $s ) { + $unit = $s->name; + $title = Title::makeTitle( NS_TRANSLATIONS, "$prefix/$unit/$code" ); + + $fuzzy = $s->type === 'changed'; + $jobs[] = MessageUpdateJob::newJob( $title, $s->getTextWithVariables(), $fuzzy ); + } + + return $jobs; + } + + /** + * Creates jobs needed to create or update all translation pages. + * @param TranslatablePage $page + * @return Job[] + * @since 2013-01-28 + */ + public static function getRenderJobs( TranslatablePage $page ) { + $jobs = []; + + $jobTitles = $page->getTranslationPages(); + // $jobTitles may have the source language title already but duplicate TranslateRenderJobs + // are not executed so it's not run twice for the source language page present. This is + // added to ensure that we create the source language page from the very beginning. + $sourceLangTitle = $page->getTitle()->getSubpage( $page->getSourceLanguageCode() ); + $jobTitles[] = $sourceLangTitle; + foreach ( $jobTitles as $t ) { + $jobs[] = TranslateRenderJob::newJob( $t ); + } + + return $jobs; + } + +} |