Merge "Update README file for Selenium tests"
authorjenkins-bot <jenkins-bot@gerrit.wikimedia.org>
Wed, 21 Feb 2018 17:37:47 +0000 (17:37 +0000)
committerGerrit Code Review <gerrit@wikimedia.org>
Wed, 21 Feb 2018 17:37:47 +0000 (17:37 +0000)
17 files changed:
autoload.php
includes/MediaWikiServices.php
includes/ServiceWiring.php
includes/changetags/ChangeTags.php
includes/import/ImportableOldRevision.php [new file with mode: 0644]
includes/import/ImportableOldRevisionImporter.php [new file with mode: 0644]
includes/import/ImportableUploadRevision.php [new file with mode: 0644]
includes/import/ImportableUploadRevisionImporter.php [new file with mode: 0644]
includes/import/OldRevisionImporter.php [new file with mode: 0644]
includes/import/UploadRevisionImporter.php [new file with mode: 0644]
includes/import/WikiRevision.php
includes/libs/rdbms/database/Database.php
includes/specialpage/ChangesListSpecialPage.php
languages/Language.php
languages/data/Names.php
tests/phpunit/includes/libs/rdbms/database/DatabaseSQLTest.php
tests/phpunit/languages/LanguageTest.php

index 9042f7b..7f90d47 100644 (file)
@@ -647,6 +647,10 @@ $wgAutoloadLocalClasses = [
        'ImportStringSource' => __DIR__ . '/includes/import/ImportStringSource.php',
        'ImportTextFiles' => __DIR__ . '/maintenance/importTextFiles.php',
        'ImportTitleFactory' => __DIR__ . '/includes/title/ImportTitleFactory.php',
+       'ImportableOldRevision' => __DIR__ . '/includes/import/ImportableOldRevision.php',
+       'ImportableOldRevisionImporter' => __DIR__ . '/includes/import/ImportableOldRevisionImporter.php',
+       'ImportableUploadRevision' => __DIR__ . '/includes/import/ImportableUploadRevision.php',
+       'ImportableUploadRevisionImporter' => __DIR__ . '/includes/import/ImportableUploadRevisionImporter.php',
        'IncludableSpecialPage' => __DIR__ . '/includes/specialpage/IncludableSpecialPage.php',
        'IndexPager' => __DIR__ . '/includes/pager/IndexPager.php',
        'InfoAction' => __DIR__ . '/includes/actions/InfoAction.php',
@@ -1075,6 +1079,7 @@ $wgAutoloadLocalClasses = [
        'ObjectFactory' => __DIR__ . '/includes/compat/ObjectFactory.php',
        'OldChangesList' => __DIR__ . '/includes/changes/OldChangesList.php',
        'OldLocalFile' => __DIR__ . '/includes/filerepo/file/OldLocalFile.php',
+       'OldRevisionImporter' => __DIR__ . '/includes/import/OldRevisionImporter.php',
        'OracleInstaller' => __DIR__ . '/includes/installer/OracleInstaller.php',
        'OracleUpdater' => __DIR__ . '/includes/installer/OracleUpdater.php',
        'OrderedStreamingForkController' => __DIR__ . '/includes/OrderedStreamingForkController.php',
@@ -1570,6 +1575,7 @@ $wgAutoloadLocalClasses = [
        'UploadFromStash' => __DIR__ . '/includes/upload/UploadFromStash.php',
        'UploadFromUrl' => __DIR__ . '/includes/upload/UploadFromUrl.php',
        'UploadLogFormatter' => __DIR__ . '/includes/logging/UploadLogFormatter.php',
+       'UploadRevisionImporter' => __DIR__ . '/includes/import/UploadRevisionImporter.php',
        'UploadSourceAdapter' => __DIR__ . '/includes/import/UploadSourceAdapter.php',
        'UploadSourceField' => __DIR__ . '/includes/specials/formfields/UploadSourceField.php',
        'UploadStash' => __DIR__ . '/includes/upload/UploadStash.php',
index 9077666..59f194d 100644 (file)
@@ -690,6 +690,30 @@ class MediaWikiServices extends ServiceContainer {
                return $this->getService( 'ReadOnlyMode' );
        }
 
+       /**
+        * @since 1.31
+        * @return \UploadRevisionImporter
+        */
+       public function getWikiRevisionUploadImporter() {
+               return $this->getService( 'UploadRevisionImporter' );
+       }
+
+       /**
+        * @since 1.31
+        * @return \OldRevisionImporter
+        */
+       public function getWikiRevisionOldRevisionImporter() {
+               return $this->getService( 'OldRevisionImporter' );
+       }
+
+       /**
+        * @since 1.31
+        * @return \OldRevisionImporter
+        */
+       public function getWikiRevisionOldRevisionImporterNoUpdates() {
+               return $this->getService( 'WikiRevisionOldRevisionImporterNoUpdates' );
+       }
+
        /**
         * @since 1.30
         * @return CommandFactory
index 8b0452d..dab9fb9 100644 (file)
@@ -442,6 +442,29 @@ return [
                );
        },
 
+       'UploadRevisionImporter' => function ( MediaWikiServices $services ) {
+               return new ImportableUploadRevisionImporter(
+                       $services->getMainConfig()->get( 'EnableUploads' ),
+                       LoggerFactory::getInstance( 'UploadRevisionImporter' )
+               );
+       },
+
+       'OldRevisionImporter' => function ( MediaWikiServices $services ) {
+               return new ImportableOldRevisionImporter(
+                       true,
+                       LoggerFactory::getInstance( 'OldRevisionImporter' ),
+                       $services->getDBLoadBalancer()
+               );
+       },
+
+       'WikiRevisionOldRevisionImporterNoUpdates' => function ( MediaWikiServices $services ) {
+               return new ImportableOldRevisionImporter(
+                       false,
+                       LoggerFactory::getInstance( 'OldRevisionImporter' ),
+                       $services->getDBLoadBalancer()
+               );
+       },
+
        'ShellCommandFactory' => function ( MediaWikiServices $services ) {
                $config = $services->getMainConfig();
 
index 7e4dd00..b30b82d 100644 (file)
@@ -181,6 +181,28 @@ class ChangeTags {
                return $msg;
        }
 
+       /**
+        * Get truncated message for the tag's long description.
+        *
+        * @param string $tag Tag name.
+        * @param int $length Maximum length of truncated message, including ellipsis.
+        * @param IContextSource $context
+        *
+        * @return string Truncated long tag description.
+        */
+       public static function truncateTagDescription( $tag, $length, IContextSource $context ) {
+               $originalDesc = self::tagLongDescriptionMessage( $tag, $context );
+               // If there is no tag description, return empty string
+               if ( !$originalDesc ) {
+                       return '';
+               }
+
+               $taglessDesc = Sanitizer::stripAllTags( $originalDesc->parse() );
+               $escapedDesc = Sanitizer::escapeHtmlAllowEntities( $taglessDesc );
+
+               return $context->getLanguage()->truncateForVisual( $escapedDesc, $length );
+       }
+
        /**
         * Add tags to a change given its rc_id, rev_id and/or log_id
         *
diff --git a/includes/import/ImportableOldRevision.php b/includes/import/ImportableOldRevision.php
new file mode 100644 (file)
index 0000000..6d1e242
--- /dev/null
@@ -0,0 +1,68 @@
+<?php
+
+/**
+ * @since 1.31
+ */
+interface ImportableOldRevision {
+
+       /**
+        * @since 1.31
+        * @return User
+        */
+       public function getUserObj();
+
+       /**
+        * @since 1.31
+        * @return string
+        */
+       public function getUser();
+
+       /**
+        * @since 1.31
+        * @return Title
+        */
+       public function getTitle();
+
+       /**
+        * @since 1.31
+        * @return string
+        */
+       public function getTimestamp();
+
+       /**
+        * @since 1.31
+        * @return string
+        */
+       public function getComment();
+
+       /**
+        * @since 1.31
+        * @return string
+        */
+       public function getModel();
+
+       /**
+        * @since 1.31
+        * @return string
+        */
+       public function getFormat();
+
+       /**
+        * @since 1.31
+        * @return Content
+        */
+       public function getContent();
+
+       /**
+        * @since 1.31
+        * @return bool
+        */
+       public function getMinor();
+
+       /**
+        * @since 1.31
+        * @return bool|string
+        */
+       public function getSha1Base36();
+
+}
diff --git a/includes/import/ImportableOldRevisionImporter.php b/includes/import/ImportableOldRevisionImporter.php
new file mode 100644 (file)
index 0000000..33fad3e
--- /dev/null
@@ -0,0 +1,143 @@
+<?php
+
+use Psr\Log\LoggerInterface;
+use Wikimedia\Rdbms\LoadBalancer;
+
+/**
+ * @since 1.31
+ */
+class ImportableOldRevisionImporter implements OldRevisionImporter {
+
+       /**
+        * @var LoggerInterface
+        */
+       private $logger;
+
+       /**
+        * @var bool
+        */
+       private $doUpdates;
+
+       /**
+        * @var LoadBalancer
+        */
+       private $loadBalancer;
+
+       /**
+        * @param bool $doUpdates
+        * @param LoggerInterface $logger
+        * @param LoadBalancer $loadBalancer
+        */
+       public function __construct(
+               $doUpdates,
+               LoggerInterface $logger,
+               LoadBalancer $loadBalancer
+       ) {
+               $this->doUpdates = $doUpdates;
+               $this->logger = $logger;
+               $this->loadBalancer = $loadBalancer;
+       }
+
+       public function import( ImportableOldRevision $importableRevision, $doUpdates = true ) {
+               $dbw = $this->loadBalancer->getConnectionRef( DB_MASTER );
+
+               # Sneak a single revision into place
+               $user = $importableRevision->getUserObj() ?: User::newFromName( $importableRevision->getUser() );
+               if ( $user ) {
+                       $userId = intval( $user->getId() );
+                       $userText = $user->getName();
+               } else {
+                       $userId = 0;
+                       $userText = $importableRevision->getUser();
+                       $user = new User;
+               }
+
+               // avoid memory leak...?
+               Title::clearCaches();
+
+               $page = WikiPage::factory( $importableRevision->getTitle() );
+               $page->loadPageData( 'fromdbmaster' );
+               if ( !$page->exists() ) {
+                       // must create the page...
+                       $pageId = $page->insertOn( $dbw );
+                       $created = true;
+                       $oldcountable = null;
+               } else {
+                       $pageId = $page->getId();
+                       $created = false;
+
+                       // Note: sha1 has been in XML dumps since 2012. If you have an
+                       // older dump, the duplicate detection here won't work.
+                       $prior = $dbw->selectField( 'revision', '1',
+                               [ 'rev_page' => $pageId,
+                                       'rev_timestamp' => $dbw->timestamp( $importableRevision->getTimestamp() ),
+                                       'rev_sha1' => $importableRevision->getSha1Base36() ],
+                               __METHOD__
+                       );
+                       if ( $prior ) {
+                               // @todo FIXME: This could fail slightly for multiple matches :P
+                               $this->logger->debug( __METHOD__ . ": skipping existing revision for [[" .
+                                       $importableRevision->getTitle()->getPrefixedText() . "]], timestamp " .
+                                       $importableRevision->getTimestamp() . "\n" );
+                               return false;
+                       }
+               }
+
+               if ( !$pageId ) {
+                       // This seems to happen if two clients simultaneously try to import the
+                       // same page
+                       $this->logger->debug( __METHOD__ . ': got invalid $pageId when importing revision of [[' .
+                               $importableRevision->getTitle()->getPrefixedText() . ']], timestamp ' .
+                               $importableRevision->getTimestamp() . "\n" );
+                       return false;
+               }
+
+               // Select previous version to make size diffs correct
+               // @todo This assumes that multiple revisions of the same page are imported
+               // in order from oldest to newest.
+               $prevId = $dbw->selectField( 'revision', 'rev_id',
+                       [
+                               'rev_page' => $pageId,
+                               'rev_timestamp <= ' . $dbw->addQuotes( $dbw->timestamp( $importableRevision->getTimestamp() ) ),
+                       ],
+                       __METHOD__,
+                       [ 'ORDER BY' => [
+                               'rev_timestamp DESC',
+                               'rev_id DESC', // timestamp is not unique per page
+                       ]
+                       ]
+               );
+
+               # @todo FIXME: Use original rev_id optionally (better for backups)
+               # Insert the row
+               $revision = new Revision( [
+                       'title' => $importableRevision->getTitle(),
+                       'page' => $pageId,
+                       'content_model' => $importableRevision->getModel(),
+                       'content_format' => $importableRevision->getFormat(),
+                       // XXX: just set 'content' => $wikiRevision->getContent()?
+                       'text' => $importableRevision->getContent()->serialize( $importableRevision->getFormat() ),
+                       'comment' => $importableRevision->getComment(),
+                       'user' => $userId,
+                       'user_text' => $userText,
+                       'timestamp' => $importableRevision->getTimestamp(),
+                       'minor_edit' => $importableRevision->getMinor(),
+                       'parent_id' => $prevId,
+               ] );
+               $revision->insertOn( $dbw );
+               $changed = $page->updateIfNewerOn( $dbw, $revision );
+
+               if ( $changed !== false && $this->doUpdates ) {
+                       $this->logger->debug( __METHOD__ . ": running updates\n" );
+                       // countable/oldcountable stuff is handled in WikiImporter::finishImportPage
+                       $page->doEditUpdates(
+                               $revision,
+                               $user,
+                               [ 'created' => $created, 'oldcountable' => 'no-change' ]
+                       );
+               }
+
+               return true;
+       }
+
+}
diff --git a/includes/import/ImportableUploadRevision.php b/includes/import/ImportableUploadRevision.php
new file mode 100644 (file)
index 0000000..3f60112
--- /dev/null
@@ -0,0 +1,68 @@
+<?php
+
+/**
+ * @since 1.31
+ */
+interface ImportableUploadRevision {
+
+       /**
+        * @since 1.31
+        * @return string Archive name of a revision if archived.
+        */
+       public function getArchiveName();
+
+       /**
+        * @since 1.31
+        * @return Title
+        */
+       public function getTitle();
+
+       /**
+        * @since 1.31
+        * @return string
+        */
+       public function getTimestamp();
+
+       /**
+        * @since 1.31
+        * @return string|null HTTP source of revision to be used for downloading.
+        */
+       public function getSrc();
+
+       /**
+        * @since 1.31
+        * @return string Local file source of the revision.
+        */
+       public function getFileSrc();
+
+       /**
+        * @since 1.31
+        * @return bool Is the return of getFileSrc only temporary?
+        */
+       public function isTempSrc();
+
+       /**
+        * @since 1.31
+        * @return string|bool sha1 of the revision, false if not set or errors occour.
+        */
+       public function getSha1();
+
+       /**
+        * @since 1.31
+        * @return User
+        */
+       public function getUserObj();
+
+       /**
+        * @since 1.31
+        * @return string The username of the user that created this revision
+        */
+       public function getUser();
+
+       /**
+        * @since 1.31
+        * @return string
+        */
+       public function getComment();
+
+}
diff --git a/includes/import/ImportableUploadRevisionImporter.php b/includes/import/ImportableUploadRevisionImporter.php
new file mode 100644 (file)
index 0000000..495b3d6
--- /dev/null
@@ -0,0 +1,154 @@
+<?php
+
+use Psr\Log\LoggerInterface;
+
+/**
+ * @since 1.31
+ */
+class ImportableUploadRevisionImporter implements UploadRevisionImporter {
+
+       /**
+        * @var LoggerInterface
+        */
+       private $logger;
+
+       /**
+        * @var bool
+        */
+       private $enableUploads;
+
+       /**
+        * @param bool $enableUploads
+        * @param LoggerInterface $logger
+        */
+       public function __construct(
+               $enableUploads,
+               LoggerInterface $logger
+       ) {
+               $this->enableUploads = $enableUploads;
+               $this->logger = $logger;
+       }
+
+       /**
+        * @return StatusValue
+        */
+       private function newNotOkStatus() {
+               $statusValue = new StatusValue();
+               $statusValue->setOK( false );
+               return $statusValue;
+       }
+
+       public function import( ImportableUploadRevision $importableRevision ) {
+               # Construct a file
+               $archiveName = $importableRevision->getArchiveName();
+               if ( $archiveName ) {
+                       $this->logger->debug( __METHOD__ . "Importing archived file as $archiveName\n" );
+                       $file = OldLocalFile::newFromArchiveName( $importableRevision->getTitle(),
+                               RepoGroup::singleton()->getLocalRepo(), $archiveName );
+               } else {
+                       $file = wfLocalFile( $importableRevision->getTitle() );
+                       $file->load( File::READ_LATEST );
+                       $this->logger->debug( __METHOD__ . 'Importing new file as ' . $file->getName() . "\n" );
+                       if ( $file->exists() && $file->getTimestamp() > $importableRevision->getTimestamp() ) {
+                               $archiveName = $file->getTimestamp() . '!' . $file->getName();
+                               $file = OldLocalFile::newFromArchiveName( $importableRevision->getTitle(),
+                                       RepoGroup::singleton()->getLocalRepo(), $archiveName );
+                               $this->logger->debug( __METHOD__ . "File already exists; importing as $archiveName\n" );
+                       }
+               }
+               if ( !$file ) {
+                       $this->logger->debug( __METHOD__ . ': Bad file for ' . $importableRevision->getTitle() . "\n" );
+                       return $this->newNotOkStatus();
+               }
+
+               # Get the file source or download if necessary
+               $source = $importableRevision->getFileSrc();
+               $autoDeleteSource = $importableRevision->isTempSrc();
+               if ( !strlen( $source ) ) {
+                       $source = $this->downloadSource( $importableRevision );
+                       $autoDeleteSource = true;
+               }
+               if ( !strlen( $source ) ) {
+                       $this->logger->debug( __METHOD__ . ": Could not fetch remote file.\n" );
+                       return $this->newNotOkStatus();
+               }
+
+               $tmpFile = new TempFSFile( $source );
+               if ( $autoDeleteSource ) {
+                       $tmpFile->autocollect();
+               }
+
+               $sha1File = ltrim( sha1_file( $source ), '0' );
+               $sha1 = $importableRevision->getSha1();
+               if ( $sha1 && ( $sha1 !== $sha1File ) ) {
+                       $this->logger->debug( __METHOD__ . ": Corrupt file $source.\n" );
+                       return $this->newNotOkStatus();
+               }
+
+               $user = $importableRevision->getUserObj() ?: User::newFromName( $importableRevision->getUser() );
+
+               # Do the actual upload
+               if ( $archiveName ) {
+                       $status = $file->uploadOld( $source, $archiveName,
+                               $importableRevision->getTimestamp(), $importableRevision->getComment(), $user );
+               } else {
+                       $flags = 0;
+                       $status = $file->upload(
+                               $source,
+                               $importableRevision->getComment(),
+                               $importableRevision->getComment(),
+                               $flags,
+                               false,
+                               $importableRevision->getTimestamp(),
+                               $user
+                       );
+               }
+
+               if ( $status->isGood() ) {
+                       $this->logger->debug( __METHOD__ . ": Successful\n" );
+               } else {
+                       $this->logger->debug( __METHOD__ . ': failed: ' . $status->getHTML() . "\n" );
+               }
+
+               return $status;
+       }
+
+       /**
+        * @deprecated DO NOT CALL ME.
+        * This method was introduced when factoring UploadImporter out of WikiRevision.
+        * It only has 1 use by the deprecated downloadSource method in WikiRevision.
+        * Do not use this in new code.
+        *
+        * @param ImportableUploadRevision $wikiRevision
+        *
+        * @return bool|string
+        */
+       public function downloadSource( ImportableUploadRevision $wikiRevision ) {
+               if ( !$this->enableUploads ) {
+                       return false;
+               }
+
+               $tempo = tempnam( wfTempDir(), 'download' );
+               $f = fopen( $tempo, 'wb' );
+               if ( !$f ) {
+                       $this->logger->debug( "IMPORT: couldn't write to temp file $tempo\n" );
+                       return false;
+               }
+
+               // @todo FIXME!
+               $src = $wikiRevision->getSrc();
+               $data = Http::get( $src, [], __METHOD__ );
+               if ( !$data ) {
+                       $this->logger->debug( "IMPORT: couldn't fetch source $src\n" );
+                       fclose( $f );
+                       unlink( $tempo );
+                       return false;
+               }
+
+               fwrite( $f, $data );
+               fclose( $f );
+
+               return $tempo;
+       }
+
+}
diff --git a/includes/import/OldRevisionImporter.php b/includes/import/OldRevisionImporter.php
new file mode 100644 (file)
index 0000000..72af43b
--- /dev/null
@@ -0,0 +1,17 @@
+<?php
+
+/**
+ * @since 1.31
+ */
+interface OldRevisionImporter {
+
+       /**
+        * @since 1.31
+        *
+        * @param ImportableOldRevision $importableRevision
+        *
+        * @return bool Success
+        */
+       public function import( ImportableOldRevision $importableRevision );
+
+}
diff --git a/includes/import/UploadRevisionImporter.php b/includes/import/UploadRevisionImporter.php
new file mode 100644 (file)
index 0000000..966fc11
--- /dev/null
@@ -0,0 +1,18 @@
+<?php
+
+/**
+ * @since 1.31
+ */
+interface UploadRevisionImporter {
+
+       /**
+        * @since 1.31
+        *
+        * @param ImportableUploadRevision $importableUploadRevision
+        *
+        * @return StatusValue On success, the value member contains the
+        *     archive name, or an empty string if it was a new file.
+        */
+       public function import( ImportableUploadRevision $importableUploadRevision );
+
+}
index 3513f8c..4325a1a 100644 (file)
@@ -23,6 +23,8 @@
  * @file
  * @ingroup SpecialPage
  */
+use MediaWiki\Logger\LoggerFactory;
+use MediaWiki\MediaWikiServices;
 
 /**
  * Represents a revision, log entry or upload during the import process.
@@ -32,7 +34,7 @@
  *
  * @ingroup SpecialPage
  */
-class WikiRevision {
+class WikiRevision implements ImportableUploadRevision, ImportableOldRevision {
 
        /**
         * @since 1.17
@@ -170,9 +172,9 @@ class WikiRevision {
 
        /**
         * @since 1.12.2
-        * @var mixed
+        * @var string|null
         */
-       protected $src;
+       protected $src = null;
 
        /**
         * @since 1.18
@@ -298,7 +300,7 @@ class WikiRevision {
 
        /**
         * @since 1.12.2
-        * @param mixed $src
+        * @param string|null $src
         */
        public function setSrc( $src ) {
                $this->src = $src;
@@ -494,7 +496,7 @@ class WikiRevision {
 
        /**
         * @since 1.12.2
-        * @return mixed
+        * @return string|null
         */
        public function getSrc() {
                return $this->src;
@@ -511,6 +513,17 @@ class WikiRevision {
                return false;
        }
 
+       /**
+        * @since 1.31
+        * @return bool|string
+        */
+       public function getSha1Base36() {
+               if ( $this->sha1base36 ) {
+                       return $this->sha1base36;
+               }
+               return false;
+       }
+
        /**
         * @since 1.17
         * @return string
@@ -577,106 +590,16 @@ class WikiRevision {
 
        /**
         * @since 1.4.1
+        * @deprecated in 1.31. Use OldRevisionImporter::import
         * @return bool
         */
        public function importOldRevision() {
-               $dbw = wfGetDB( DB_MASTER );
-
-               # Sneak a single revision into place
-               $user = $this->getUserObj() ?: User::newFromName( $this->getUser() );
-               if ( $user ) {
-                       $userId = intval( $user->getId() );
-                       $userText = $user->getName();
-               } else {
-                       $userId = 0;
-                       $userText = $this->getUser();
-                       $user = new User;
-               }
-
-               // avoid memory leak...?
-               Title::clearCaches();
-
-               $page = WikiPage::factory( $this->title );
-               $page->loadPageData( 'fromdbmaster' );
-               if ( !$page->exists() ) {
-                       // must create the page...
-                       $pageId = $page->insertOn( $dbw );
-                       $created = true;
-                       $oldcountable = null;
+               if ( $this->mNoUpdates ) {
+                       $importer = MediaWikiServices::getInstance()->getWikiRevisionOldRevisionImporterNoUpdates();
                } else {
-                       $pageId = $page->getId();
-                       $created = false;
-
-                       // Note: sha1 has been in XML dumps since 2012. If you have an
-                       // older dump, the duplicate detection here won't work.
-                       $prior = $dbw->selectField( 'revision', '1',
-                               [ 'rev_page' => $pageId,
-                                       'rev_timestamp' => $dbw->timestamp( $this->timestamp ),
-                                       'rev_sha1' => $this->sha1base36 ],
-                               __METHOD__
-                       );
-                       if ( $prior ) {
-                               // @todo FIXME: This could fail slightly for multiple matches :P
-                               wfDebug( __METHOD__ . ": skipping existing revision for [[" .
-                                       $this->title->getPrefixedText() . "]], timestamp " . $this->timestamp . "\n" );
-                               return false;
-                       }
-               }
-
-               if ( !$pageId ) {
-                       // This seems to happen if two clients simultaneously try to import the
-                       // same page
-                       wfDebug( __METHOD__ . ': got invalid $pageId when importing revision of [[' .
-                               $this->title->getPrefixedText() . ']], timestamp ' . $this->timestamp . "\n" );
-                       return false;
+                       $importer = MediaWikiServices::getInstance()->getWikiRevisionOldRevisionImporter();
                }
-
-               // Select previous version to make size diffs correct
-               // @todo This assumes that multiple revisions of the same page are imported
-               // in order from oldest to newest.
-               $prevId = $dbw->selectField( 'revision', 'rev_id',
-                       [
-                               'rev_page' => $pageId,
-                               'rev_timestamp <= ' . $dbw->addQuotes( $dbw->timestamp( $this->timestamp ) ),
-                       ],
-                       __METHOD__,
-                       [ 'ORDER BY' => [
-                                       'rev_timestamp DESC',
-                                       'rev_id DESC', // timestamp is not unique per page
-                               ]
-                       ]
-               );
-
-               # @todo FIXME: Use original rev_id optionally (better for backups)
-               # Insert the row
-               $revision = new Revision( [
-                       'title' => $this->title,
-                       'page' => $pageId,
-                       'content_model' => $this->getModel(),
-                       'content_format' => $this->getFormat(),
-                       // XXX: just set 'content' => $this->getContent()?
-                       'text' => $this->getContent()->serialize( $this->getFormat() ),
-                       'comment' => $this->getComment(),
-                       'user' => $userId,
-                       'user_text' => $userText,
-                       'timestamp' => $this->timestamp,
-                       'minor_edit' => $this->minor,
-                       'parent_id' => $prevId,
-                       ] );
-               $revision->insertOn( $dbw );
-               $changed = $page->updateIfNewerOn( $dbw, $revision );
-
-               if ( $changed !== false && !$this->mNoUpdates ) {
-                       wfDebug( __METHOD__ . ": running updates\n" );
-                       // countable/oldcountable stuff is handled in WikiImporter::finishImportPage
-                       $page->doEditUpdates(
-                               $revision,
-                               $user,
-                               [ 'created' => $created, 'oldcountable' => 'no-change' ]
-                       );
-               }
-
-               return true;
+               return $importer->import( $this );
        }
 
        /**
@@ -737,106 +660,26 @@ class WikiRevision {
 
        /**
         * @since 1.12.2
+        * @deprecated in 1.31. Use UploadImporter::import
         * @return bool
         */
        public function importUpload() {
-               # Construct a file
-               $archiveName = $this->getArchiveName();
-               if ( $archiveName ) {
-                       wfDebug( __METHOD__ . "Importing archived file as $archiveName\n" );
-                       $file = OldLocalFile::newFromArchiveName( $this->getTitle(),
-                               RepoGroup::singleton()->getLocalRepo(), $archiveName );
-               } else {
-                       $file = wfLocalFile( $this->getTitle() );
-                       $file->load( File::READ_LATEST );
-                       wfDebug( __METHOD__ . 'Importing new file as ' . $file->getName() . "\n" );
-                       if ( $file->exists() && $file->getTimestamp() > $this->getTimestamp() ) {
-                               $archiveName = $file->getTimestamp() . '!' . $file->getName();
-                               $file = OldLocalFile::newFromArchiveName( $this->getTitle(),
-                                       RepoGroup::singleton()->getLocalRepo(), $archiveName );
-                               wfDebug( __METHOD__ . "File already exists; importing as $archiveName\n" );
-                       }
-               }
-               if ( !$file ) {
-                       wfDebug( __METHOD__ . ': Bad file for ' . $this->getTitle() . "\n" );
-                       return false;
-               }
-
-               # Get the file source or download if necessary
-               $source = $this->getFileSrc();
-               $autoDeleteSource = $this->isTempSrc();
-               if ( !strlen( $source ) ) {
-                       $source = $this->downloadSource();
-                       $autoDeleteSource = true;
-               }
-               if ( !strlen( $source ) ) {
-                       wfDebug( __METHOD__ . ": Could not fetch remote file.\n" );
-                       return false;
-               }
-
-               $tmpFile = new TempFSFile( $source );
-               if ( $autoDeleteSource ) {
-                       $tmpFile->autocollect();
-               }
-
-               $sha1File = ltrim( sha1_file( $source ), '0' );
-               $sha1 = $this->getSha1();
-               if ( $sha1 && ( $sha1 !== $sha1File ) ) {
-                       wfDebug( __METHOD__ . ": Corrupt file $source.\n" );
-                       return false;
-               }
-
-               $user = $this->getUserObj() ?: User::newFromName( $this->getUser() );
-
-               # Do the actual upload
-               if ( $archiveName ) {
-                       $status = $file->uploadOld( $source, $archiveName,
-                               $this->getTimestamp(), $this->getComment(), $user );
-               } else {
-                       $flags = 0;
-                       $status = $file->upload( $source, $this->getComment(), $this->getComment(),
-                               $flags, false, $this->getTimestamp(), $user );
-               }
-
-               if ( $status->isGood() ) {
-                       wfDebug( __METHOD__ . ": Successful\n" );
-                       return true;
-               } else {
-                       wfDebug( __METHOD__ . ': failed: ' . $status->getHTML() . "\n" );
-                       return false;
-               }
+               $importer = MediaWikiServices::getInstance()->getWikiRevisionUploadImporter();
+               $statusValue = $importer->import( $this );
+               return $statusValue->isGood();
        }
 
        /**
         * @since 1.12.2
+        * @deprecated in 1.31. Use UploadImporter::downloadSource
         * @return bool|string
         */
        public function downloadSource() {
-               if ( !$this->config->get( 'EnableUploads' ) ) {
-                       return false;
-               }
-
-               $tempo = tempnam( wfTempDir(), 'download' );
-               $f = fopen( $tempo, 'wb' );
-               if ( !$f ) {
-                       wfDebug( "IMPORT: couldn't write to temp file $tempo\n" );
-                       return false;
-               }
-
-               // @todo FIXME!
-               $src = $this->getSrc();
-               $data = Http::get( $src, [], __METHOD__ );
-               if ( !$data ) {
-                       wfDebug( "IMPORT: couldn't fetch source $src\n" );
-                       fclose( $f );
-                       unlink( $tempo );
-                       return false;
-               }
-
-               fwrite( $f, $data );
-               fclose( $f );
-
-               return $tempo;
+               $importer = new ImportableUploadRevisionImporter(
+                       $this->config->get( 'EnableUploads' ),
+                       LoggerFactory::getInstance( 'UploadRevisionImporter' )
+               );
+               return $importer->downloadSource( $this );
        }
 
 }
index 9a8996c..572a798 100644 (file)
@@ -2244,37 +2244,51 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
                        $rows = [ $rows ];
                }
 
-               $affectedRowCount = 0;
-               foreach ( $rows as $row ) {
-                       // Delete rows which collide with this one
-                       $indexWhereClauses = [];
-                       foreach ( $uniqueIndexes as $index ) {
-                               $indexColumns = (array)$index;
-                               $indexRowValues = array_intersect_key( $row, array_flip( $indexColumns ) );
-                               if ( count( $indexRowValues ) != count( $indexColumns ) ) {
-                                       throw new DBUnexpectedError(
-                                               $this,
-                                               'New record does not provide all values for unique key (' .
+               $useTrx = !$this->trxLevel;
+               if ( $useTrx ) {
+                       $this->begin( $fname, self::TRANSACTION_INTERNAL );
+               }
+               try {
+                       $affectedRowCount = 0;
+                       foreach ( $rows as $row ) {
+                               // Delete rows which collide with this one
+                               $indexWhereClauses = [];
+                               foreach ( $uniqueIndexes as $index ) {
+                                       $indexColumns = (array)$index;
+                                       $indexRowValues = array_intersect_key( $row, array_flip( $indexColumns ) );
+                                       if ( count( $indexRowValues ) != count( $indexColumns ) ) {
+                                               throw new DBUnexpectedError(
+                                                       $this,
+                                                       'New record does not provide all values for unique key (' .
                                                        implode( ', ', $indexColumns ) . ')'
-                                       );
-                               } elseif ( in_array( null, $indexRowValues, true ) ) {
-                                       throw new DBUnexpectedError(
-                                               $this,
-                                               'New record has a null value for unique key (' .
+                                               );
+                                       } elseif ( in_array( null, $indexRowValues, true ) ) {
+                                               throw new DBUnexpectedError(
+                                                       $this,
+                                                       'New record has a null value for unique key (' .
                                                        implode( ', ', $indexColumns ) . ')'
-                                       );
+                                               );
+                                       }
+                                       $indexWhereClauses[] = $this->makeList( $indexRowValues, LIST_AND );
                                }
-                               $indexWhereClauses[] = $this->makeList( $indexRowValues, LIST_AND );
-                       }
 
-                       if ( $indexWhereClauses ) {
-                               $this->delete( $table, $this->makeList( $indexWhereClauses, LIST_OR ), $fname );
+                               if ( $indexWhereClauses ) {
+                                       $this->delete( $table, $this->makeList( $indexWhereClauses, LIST_OR ), $fname );
+                                       $affectedRowCount += $this->affectedRows();
+                               }
+
+                               // Now insert the row
+                               $this->insert( $table, $row, $fname );
                                $affectedRowCount += $this->affectedRows();
                        }
-
-                       // Now insert the row
-                       $this->insert( $table, $row, $fname );
-                       $affectedRowCount += $this->affectedRows();
+               } catch ( Exception $e ) {
+                       if ( $useTrx ) {
+                               $this->rollback( $fname, self::FLUSHING_INTERNAL );
+                       }
+                       throw $e;
+               }
+               if ( $useTrx ) {
+                       $this->commit( $fname, self::FLUSHING_INTERNAL );
                }
 
                $this->affectedRowCount = $affectedRowCount;
index cf990c2..5aa1c6b 100644 (file)
@@ -33,6 +33,12 @@ use Wikimedia\Rdbms\IDatabase;
  * @ingroup SpecialPage
  */
 abstract class ChangesListSpecialPage extends SpecialPage {
+       /**
+        * Maximum length of a tag description in UTF-8 characters.
+        * Longer descriptions will be truncated.
+        */
+       const TAG_DESC_CHARACTER_LIMIT = 120;
+
        /**
         * Preference name for saved queries. Subclasses that use saved queries should override this.
         * @var string
@@ -794,15 +800,15 @@ abstract class ChangesListSpecialPage extends SpecialPage {
                                                isset( $explicitlyDefinedTags[ $tagName ] ) ||
                                                isset( $softwareActivatedTags[ $tagName ] )
                                        ) {
-                                               // Parse description
-                                               $desc = ChangeTags::tagLongDescriptionMessage( $tagName, $context );
-
                                                $result[] = [
                                                        'name' => $tagName,
                                                        'label' => Sanitizer::stripAllTags(
                                                                ChangeTags::tagDescription( $tagName, $context )
                                                        ),
-                                                       'description' => $desc ? Sanitizer::stripAllTags( $desc->parse() ) : '',
+                                                       'description' =>
+                                                               ChangeTags::truncateTagDescription(
+                                                                       $tagName, self::TAG_DESC_CHARACTER_LIMIT, $context
+                                                               ),
                                                        'cssClass' => Sanitizer::escapeClass( 'mw-tag-' . $tagName ),
                                                        'hits' => $hits,
                                                ];
index 084a2e7..fc8ef87 100644 (file)
@@ -3472,27 +3472,103 @@ class Language {
        }
 
        /**
-        * Truncate a string to a specified length in bytes, appending an optional
-        * string (e.g. for ellipses)
+        * This method is deprecated since 1.31 and kept as alias for truncateForDatabase, which
+        * has replaced it. This method provides truncation suitable for DB.
         *
         * The database offers limited byte lengths for some columns in the database;
         * multi-byte character sets mean we need to ensure that only whole characters
-        * are included, otherwise broken characters can be passed to the user
+        * are included, otherwise broken characters can be passed to the user.
         *
-        * If $length is negative, the string will be truncated from the beginning
+        * @deprecated since 1.31, use truncateForDatabase or truncateForVisual as appropriate.
         *
         * @param string $string String to truncate
-        * @param int $length Maximum length (including ellipses)
+        * @param int $length Maximum length (including ellipsis)
         * @param string $ellipsis String to append to the truncated text
         * @param bool $adjustLength Subtract length of ellipsis from $length.
         *      $adjustLength was introduced in 1.18, before that behaved as if false.
         * @return string
         */
        function truncate( $string, $length, $ellipsis = '...', $adjustLength = true ) {
+               return $this->truncateForDatabase( $string, $length, $ellipsis, $adjustLength );
+       }
+
+       /**
+        * Truncate a string to a specified length in bytes, appending an optional
+        * string (e.g. for ellipsis)
+        *
+        * If $length is negative, the string will be truncated from the beginning
+        *
+        * @since 1.31
+        *
+        * @param string $string String to truncate
+        * @param int $length Maximum length in bytes
+        * @param string $ellipsis String to append to the end of truncated text
+        * @param bool $adjustLength Subtract length of ellipsis from $length
+        *
+        * @return string
+        */
+       function truncateForDatabase( $string, $length, $ellipsis = '...', $adjustLength = true ) {
+               return $this->truncateInternal(
+                       $string, $length, $ellipsis, $adjustLength, 'strlen', 'substr'
+               );
+       }
+
+       /**
+        * Truncate a string to a specified number of characters, appending an optional
+        * string (e.g. for ellipsis).
+        *
+        * This provides multibyte version of truncate() method of this class, suitable for truncation
+        * based on number of characters, instead of number of bytes.
+        *
+        * If $length is negative, the string will be truncated from the beginning.
+        *
+        * @since 1.31
+        *
+        * @param string $string String to truncate
+        * @param int $length Maximum number of characters
+        * @param string $ellipsis String to append to the end of truncated text
+        * @param bool $adjustLength Subtract length of ellipsis from $length
+        *
+        * @return string
+        */
+       function truncateForVisual( $string, $length, $ellipsis = '...', $adjustLength = true ) {
+               // Passing encoding to mb_strlen and mb_substr is optional.
+               // Encoding defaults to mb_internal_encoding(), which is set to UTF-8 in Setup.php, so
+               // explicit specification of encoding is skipped.
+               // Note: Both multibyte methods are callables invoked in truncateInternal.
+               return $this->truncateInternal(
+                       $string, $length, $ellipsis, $adjustLength, 'mb_strlen', 'mb_substr'
+               );
+       }
+
+       /**
+        * Internal method used for truncation. This method abstracts text truncation into
+        * one common method, allowing users to provide length measurement function and
+        * function for finding substring.
+        *
+        * For usages, see truncateForDatabase and truncateForVisual.
+        *
+        * @param string $string String to truncate
+        * @param int $length Maximum length of final text
+        * @param string $ellipsis String to append to the end of truncated text
+        * @param bool $adjustLength Subtract length of ellipsis from $length
+        * @param callable $measureLength Callable function used for determining the length of text
+        * @param callable $getSubstring Callable function used for getting the substrings
+        *
+        * @return string
+        */
+       private function truncateInternal(
+               $string, $length, $ellipsis = '...', $adjustLength = true, $measureLength, $getSubstring
+       ) {
+               if ( !is_callable( $measureLength ) || !is_callable( $getSubstring ) ) {
+                       throw new InvalidArgumentException( 'Invalid callback provided' );
+               }
+
                # Check if there is no need to truncate
-               if ( strlen( $string ) <= abs( $length ) ) {
+               if ( $measureLength( $string ) <= abs( $length ) ) {
                        return $string; // no need to truncate
                }
+
                # Use the localized ellipsis character
                if ( $ellipsis == '...' ) {
                        $ellipsis = wfMessage( 'ellipsis' )->inLanguage( $this )->escaped();
@@ -3500,31 +3576,33 @@ class Language {
                if ( $length == 0 ) {
                        return $ellipsis; // convention
                }
+
                $stringOriginal = $string;
                # If ellipsis length is >= $length then we can't apply $adjustLength
-               if ( $adjustLength && strlen( $ellipsis ) >= abs( $length ) ) {
+               if ( $adjustLength && $measureLength( $ellipsis ) >= abs( $length ) ) {
                        $string = $ellipsis; // this can be slightly unexpected
                # Otherwise, truncate and add ellipsis...
                } else {
-                       $eLength = $adjustLength ? strlen( $ellipsis ) : 0;
+                       $ellipsisLength = $adjustLength ? $measureLength( $ellipsis ) : 0;
                        if ( $length > 0 ) {
-                               $length -= $eLength;
-                               $string = substr( $string, 0, $length ); // xyz...
+                               $length -= $ellipsisLength;
+                               $string = $getSubstring( $string, 0, $length ); // xyz...
                                $string = $this->removeBadCharLast( $string );
                                $string = rtrim( $string );
                                $string = $string . $ellipsis;
                        } else {
-                               $length += $eLength;
-                               $string = substr( $string, $length ); // ...xyz
+                               $length += $ellipsisLength;
+                               $string = $getSubstring( $string, $length ); // ...xyz
                                $string = $this->removeBadCharFirst( $string );
                                $string = ltrim( $string );
                                $string = $ellipsis . $string;
                        }
                }
+
                # Do not truncate if the ellipsis makes the string longer/equal (T24181).
                # This check is *not* redundant if $adjustLength, due to the single case where
                # LEN($ellipsis) > ABS($limit arg); $stringOriginal could be shorter than $string.
-               if ( strlen( $string ) < strlen( $stringOriginal ) ) {
+               if ( $measureLength( $string ) < $measureLength( $stringOriginal ) ) {
                        return $string;
                } else {
                        return $stringOriginal;
index 2239875..2252645 100644 (file)
@@ -261,7 +261,7 @@ class Names {
                'ky' => 'Кыргызча', # Kirghiz
                'la' => 'Latina', # Latin
                'lad' => 'Ladino', # Ladino
-               'lb' => 'Lëtzebuergesch', # Luxemburguish
+               'lb' => 'Lëtzebuergesch', # Luxembourgish
                'lbe' => 'лакку', # Lak
                'lez' => 'лезги', # Lezgi
                'lfn' => 'Lingua Franca Nova', # Lingua Franca Nova
index 184d626..ebf6e45 100644 (file)
@@ -559,11 +559,11 @@ class DatabaseSQLTest extends PHPUnit\Framework\TestCase {
                                        'uniqueIndexes' => [ 'field' ],
                                        'rows' => [ 'field' => 'text', 'field2' => 'text2' ],
                                ],
-                               "DELETE FROM replace_table " .
+                               "BEGIN; DELETE FROM replace_table " .
                                        "WHERE (field = 'text'); " .
                                        "INSERT INTO replace_table " .
                                        "(field,field2) " .
-                                       "VALUES ('text','text2')"
+                                       "VALUES ('text','text2'); COMMIT"
                        ],
                        [
                                [
@@ -575,11 +575,11 @@ class DatabaseSQLTest extends PHPUnit\Framework\TestCase {
                                                'md_deps' => 'deps',
                                        ],
                                ],
-                               "DELETE FROM module_deps " .
+                               "BEGIN; DELETE FROM module_deps " .
                                        "WHERE (md_module = 'module' AND md_skin = 'skin'); " .
                                        "INSERT INTO module_deps " .
                                        "(md_module,md_skin,md_deps) " .
-                                       "VALUES ('module','skin','deps')"
+                                       "VALUES ('module','skin','deps'); COMMIT"
                        ],
                        [
                                [
@@ -597,7 +597,7 @@ class DatabaseSQLTest extends PHPUnit\Framework\TestCase {
                                                ],
                                        ],
                                ],
-                               "DELETE FROM module_deps " .
+                               "BEGIN; DELETE FROM module_deps " .
                                        "WHERE (md_module = 'module' AND md_skin = 'skin'); " .
                                        "INSERT INTO module_deps " .
                                        "(md_module,md_skin,md_deps) " .
@@ -606,7 +606,7 @@ class DatabaseSQLTest extends PHPUnit\Framework\TestCase {
                                        "WHERE (md_module = 'module2' AND md_skin = 'skin2'); " .
                                        "INSERT INTO module_deps " .
                                        "(md_module,md_skin,md_deps) " .
-                                       "VALUES ('module2','skin2','deps2')"
+                                       "VALUES ('module2','skin2','deps2'); COMMIT"
                        ],
                        [
                                [
@@ -624,7 +624,7 @@ class DatabaseSQLTest extends PHPUnit\Framework\TestCase {
                                                ],
                                        ],
                                ],
-                               "DELETE FROM module_deps " .
+                               "BEGIN; DELETE FROM module_deps " .
                                        "WHERE (md_module = 'module') OR (md_skin = 'skin'); " .
                                        "INSERT INTO module_deps " .
                                        "(md_module,md_skin,md_deps) " .
@@ -633,7 +633,7 @@ class DatabaseSQLTest extends PHPUnit\Framework\TestCase {
                                        "WHERE (md_module = 'module2') OR (md_skin = 'skin2'); " .
                                        "INSERT INTO module_deps " .
                                        "(md_module,md_skin,md_deps) " .
-                                       "VALUES ('module2','skin2','deps2')"
+                                       "VALUES ('module2','skin2','deps2'); COMMIT"
                        ],
                        [
                                [
@@ -645,9 +645,9 @@ class DatabaseSQLTest extends PHPUnit\Framework\TestCase {
                                                'md_deps' => 'deps',
                                        ],
                                ],
-                               "INSERT INTO module_deps " .
+                               "BEGIN; INSERT INTO module_deps " .
                                        "(md_module,md_skin,md_deps) " .
-                                       "VALUES ('module','skin','deps')"
+                                       "VALUES ('module','skin','deps'); COMMIT"
                        ],
                ];
        }
index 5cb5602..050ed83 100644 (file)
@@ -209,70 +209,104 @@ class LanguageTest extends LanguageClassesTestCase {
        }
 
        /**
-        * @covers Language::truncate
+        * @covers Language::truncateForDatabase
+        * @covers Language::truncateInternal
         */
-       public function testTruncate() {
+       public function testTruncateForDatabase() {
                $this->assertEquals(
                        "XXX",
-                       $this->getLang()->truncate( "1234567890", 0, 'XXX' ),
+                       $this->getLang()->truncateForDatabase( "1234567890", 0, 'XXX' ),
                        'truncate prefix, len 0, small ellipsis'
                );
 
                $this->assertEquals(
                        "12345XXX",
-                       $this->getLang()->truncate( "1234567890", 8, 'XXX' ),
+                       $this->getLang()->truncateForDatabase( "1234567890", 8, 'XXX' ),
                        'truncate prefix, small ellipsis'
                );
 
                $this->assertEquals(
                        "123456789",
-                       $this->getLang()->truncate( "123456789", 5, 'XXXXXXXXXXXXXXX' ),
+                       $this->getLang()->truncateForDatabase( "123456789", 5, 'XXXXXXXXXXXXXXX' ),
                        'truncate prefix, large ellipsis'
                );
 
                $this->assertEquals(
                        "XXX67890",
-                       $this->getLang()->truncate( "1234567890", -8, 'XXX' ),
+                       $this->getLang()->truncateForDatabase( "1234567890", -8, 'XXX' ),
                        'truncate suffix, small ellipsis'
                );
 
                $this->assertEquals(
                        "123456789",
-                       $this->getLang()->truncate( "123456789", -5, 'XXXXXXXXXXXXXXX' ),
+                       $this->getLang()->truncateForDatabase( "123456789", -5, 'XXXXXXXXXXXXXXX' ),
                        'truncate suffix, large ellipsis'
                );
                $this->assertEquals(
                        "123XXX",
-                       $this->getLang()->truncate( "123                ", 9, 'XXX' ),
+                       $this->getLang()->truncateForDatabase( "123                ", 9, 'XXX' ),
                        'truncate prefix, with spaces'
                );
                $this->assertEquals(
                        "12345XXX",
-                       $this->getLang()->truncate( "12345            8", 11, 'XXX' ),
+                       $this->getLang()->truncateForDatabase( "12345            8", 11, 'XXX' ),
                        'truncate prefix, with spaces and non-space ending'
                );
                $this->assertEquals(
                        "XXX234",
-                       $this->getLang()->truncate( "1              234", -8, 'XXX' ),
+                       $this->getLang()->truncateForDatabase( "1              234", -8, 'XXX' ),
                        'truncate suffix, with spaces'
                );
                $this->assertEquals(
                        "12345XXX",
-                       $this->getLang()->truncate( "1234567890", 5, 'XXX', false ),
+                       $this->getLang()->truncateForDatabase( "1234567890", 5, 'XXX', false ),
                        'truncate without adjustment'
                );
                $this->assertEquals(
                        "泰乐菌...",
-                       $this->getLang()->truncate( "泰乐菌素123456789", 11, '...', false ),
+                       $this->getLang()->truncateForDatabase( "泰乐菌素123456789", 11, '...', false ),
                        'truncate does not chop Unicode characters in half'
                );
                $this->assertEquals(
                        "\n泰乐菌...",
-                       $this->getLang()->truncate( "\n泰乐菌素123456789", 12, '...', false ),
+                       $this->getLang()->truncateForDatabase( "\n泰乐菌素123456789", 12, '...', false ),
                        'truncate does not chop Unicode characters in half if there is a preceding newline'
                );
        }
 
+       /**
+        * @dataProvider provideTruncateData
+        * @covers Language::truncateForVisual
+        * @covers Language::truncateInternal
+        */
+       public function testTruncateForVisual(
+               $expected, $string, $length, $ellipsis = '...', $adjustLength = true
+       ) {
+               $this->assertEquals(
+                       $expected,
+                       $this->getLang()->truncateForVisual( $string, $length, $ellipsis, $adjustLength )
+               );
+       }
+
+       /**
+        * @return array Format is ($expected, $string, $length, $ellipsis, $adjustLength)
+        */
+       public static function provideTruncateData() {
+               return [
+                       [ "XXX", "тестирам да ли ради", 0, "XXX" ],
+                       [ "testnXXX", "testni scenarij", 8, "XXX" ],
+                       [ "حالة اختبار", "حالة اختبار", 5, "XXXXXXXXXXXXXXX" ],
+                       [ "XXXедент", "прецедент", -8, "XXX" ],
+                       [ "XXപിൾ", "ആപ്പിൾ", -5, "XX" ],
+                       [ "神秘XXX", "神秘                ", 9, "XXX" ],
+                       [ "ΔημιουργXXX", "Δημιουργία           Σύμπαντος", 11, "XXX" ],
+                       [ "XXXの家です", "地球は私たちの唯               の家です", -8, "XXX" ],
+                       [ "زندگیXXX", "زندگی زیباست", 6, "XXX", false ],
+                       [ "ცხოვრება...", "ცხოვრება არის საოცარი", 8, "...", false ],
+                       [ "\nທ່ານ...", "\nທ່ານບໍ່ຮູ້ຫນັງສື", 5, "...", false ],
+               ];
+       }
+
        /**
         * @dataProvider provideHTMLTruncateData
         * @covers Language::truncateHTML