summaryrefslogtreecommitdiff
path: root/www/wiki/extensions/Translate/tag
diff options
context:
space:
mode:
authorYaco <franco@reevo.org>2020-06-04 11:01:00 -0300
committerYaco <franco@reevo.org>2020-06-04 11:01:00 -0300
commitfc7369835258467bf97eb64f184b93691f9a9fd5 (patch)
treedaabd60089d2dd76d9f5fb416b005fbe159c799d /www/wiki/extensions/Translate/tag
first commit
Diffstat (limited to 'www/wiki/extensions/Translate/tag')
-rw-r--r--www/wiki/extensions/Translate/tag/PageTranslationHooks.php1327
-rw-r--r--www/wiki/extensions/Translate/tag/PageTranslationLogFormatter.php97
-rw-r--r--www/wiki/extensions/Translate/tag/SpecialPageMigration.php82
-rw-r--r--www/wiki/extensions/Translate/tag/SpecialPagePreparation.php71
-rw-r--r--www/wiki/extensions/Translate/tag/SpecialPageTranslation.php993
-rw-r--r--www/wiki/extensions/Translate/tag/SpecialPageTranslationDeletePage.php456
-rw-r--r--www/wiki/extensions/Translate/tag/SpecialPageTranslationMovePage.php628
-rw-r--r--www/wiki/extensions/Translate/tag/TPException.php36
-rw-r--r--www/wiki/extensions/Translate/tag/TPParse.php250
-rw-r--r--www/wiki/extensions/Translate/tag/TPSection.php175
-rw-r--r--www/wiki/extensions/Translate/tag/TranslatablePage.php900
-rw-r--r--www/wiki/extensions/Translate/tag/TranslatablePageMoveJob.php168
-rw-r--r--www/wiki/extensions/Translate/tag/TranslateDeleteJob.php172
-rw-r--r--www/wiki/extensions/Translate/tag/TranslateMoveJob.php219
-rw-r--r--www/wiki/extensions/Translate/tag/TranslateRenderJob.php112
-rw-r--r--www/wiki/extensions/Translate/tag/TranslationsUpdateJob.php127
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 .= '&lt; ';
+ }
+
+ $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;
+ }
+
+}