array ( string, string ) ) Tuples of page
* text and last author indexed by page name.
*/
public static function getContents( $titles, $namespace ) {
$dbr = wfGetDB( DB_REPLICA );
$revStore = MediaWikiServices::getInstance()->getRevisionStore();
$titleContents = [];
$query = $revStore->getQueryInfo( [ 'page', 'user' ] );
$rows = $dbr->select(
$query['tables'],
$query['fields'],
[
'page_namespace' => $namespace,
'page_title' => $titles,
'page_latest=rev_id',
],
__METHOD__,
[],
$query['joins']
);
$revisions = $revStore->newRevisionsFromBatch( $rows, [
'slots' => true,
'content' => true
] )->getValue();
foreach ( $rows as $row ) {
/** @var RevisionRecord|null $rev */
$rev = $revisions[$row->rev_id];
if ( $rev ) {
/** @var TextContent $content */
$content = $rev->getContent( SlotRecord::MAIN );
if ( $content ) {
$titleContents[$row->page_title] = [
$content->getText(),
$row->rev_user_text
];
}
}
}
$rows->free();
return $titleContents;
}
/**
* Returns the content for a given title and adds the fuzzy tag if requested.
* @param Title $title
* @param bool $addFuzzy Add the fuzzy tag if appropriate.
* @return string|null
*/
public static function getContentForTitle( Title $title, $addFuzzy = false ) {
$store = MediaWikiServices::getInstance()->getRevisionStore();
$revision = $store->getRevisionByTitle( $title );
if ( $revision === null ) {
return null;
}
$content = $revision->getContent( SlotRecord::MAIN );
$wiki = ( $content instanceof TextContent ) ? $content->getText() : null;
if ( !$wiki ) {
return null;
}
if ( !$addFuzzy ) {
return $wiki;
}
$handle = new MessageHandle( $title );
if ( $handle->isFuzzy() ) {
$wiki = TRANSLATE_FUZZY . str_replace( TRANSLATE_FUZZY, '', $wiki );
}
return $wiki;
}
/**
* Fetches recent changes for titles in given namespaces
*
* @param int $hours Number of hours.
* @param bool $bots Should bot edits be included.
* @param null|int[] $ns List of namespace IDs.
* @param string[] $extraFields List of extra columns to fetch.
* @return array List of recent changes.
*/
public static function translationChanges(
$hours = 24, $bots = false, $ns = null, array $extraFields = []
) {
global $wgTranslateMessageNamespaces;
$dbr = wfGetDB( DB_REPLICA );
$hours = (int)$hours;
$cutoff_unixtime = time() - ( $hours * 3600 );
$cutoff = $dbr->timestamp( $cutoff_unixtime );
$conds = [
'rc_timestamp >= ' . $dbr->addQuotes( $cutoff ),
'rc_namespace' => $ns ?: $wgTranslateMessageNamespaces,
];
if ( $bots ) {
$conds['rc_bot'] = 0;
}
$res = $dbr->select(
[ 'recentchanges', 'actor' ],
array_merge( [
'rc_namespace', 'rc_title', 'rc_timestamp',
'rc_user_text' => 'actor_name',
], $extraFields ),
$conds,
__METHOD__,
[],
[ 'actor' => [ 'JOIN', 'actor_id=rc_actor' ] ]
);
$rows = iterator_to_array( $res );
// Calculate 'lang', then sort by it and rc_timestamp
foreach ( $rows as &$row ) {
$pos = strrpos( $row->rc_title, '/' );
$row->lang = $pos === false ? $row->rc_title : substr( $row->rc_title, $pos + 1 );
}
unset( $row );
usort( $rows, static function ( $a, $b ) {
$x = strcmp( $a->lang, $b->lang );
if ( !$x ) {
// descending order
$x = strcmp(
wfTimestamp( TS_MW, $b->rc_timestamp ),
wfTimestamp( TS_MW, $a->rc_timestamp )
);
}
return $x;
} );
return $rows;
}
/* Some other helpers for output */
/**
* Returns a localised language name.
* @param string $code Language code.
* @param null|string $language Language code of the language that the name should be in.
* @return string Best-effort localisation of wanted language name.
*/
public static function getLanguageName( $code, $language = 'en' ) {
$languages = self::getLanguageNames( $language );
return $languages[$code] ?? $code;
}
/**
* Returns a language selector.
* @param string $language Language code of the language the names should be localised to.
* @param string $selectedId The language code that is selected by default.
* @return string
*/
public static function languageSelector( $language, $selectedId ) {
$selector = self::getLanguageSelector( $language );
$selector->setDefault( $selectedId );
$selector->setAttribute( 'id', 'language' );
$selector->setAttribute( 'name', 'language' );
return $selector->getHTML();
}
/**
* Standard language selector in Translate extension.
* @param string $language Language code of the language the names should be localised to.
* @param bool $labelOption
* @return XmlSelect
*/
public static function getLanguageSelector( $language, $labelOption = false ) {
$languages = self::getLanguageNames( $language );
ksort( $languages );
$selector = new XmlSelect();
if ( $labelOption !== false ) {
$selector->addOption( $labelOption, '-' );
}
foreach ( $languages as $code => $name ) {
$selector->addOption( "$code - $name", $code );
}
return $selector;
}
/**
* Get translated language names for the languages generally supported for
* translation in the current wiki. Message groups can have further
* exclusions.
* @param null|string $code
* @return array ( language code => language name )
*/
public static function getLanguageNames( $code ) {
$languageNames = Language::fetchLanguageNames( $code );
$deprecatedCodes = LanguageCode::getDeprecatedCodeMapping();
foreach ( array_keys( $deprecatedCodes ) as $deprecatedCode ) {
unset( $languageNames[ $deprecatedCode ] );
}
Hooks::run( 'TranslateSupportedLanguages', [ &$languageNames, $code ] );
return $languageNames;
}
/**
* Returns the primary group message belongs to.
* @param int $namespace
* @param string $key
* @return string|null Group id or null.
*/
public static function messageKeyToGroup( $namespace, $key ) {
$groups = self::messageKeyToGroups( $namespace, $key );
return count( $groups ) ? $groups[0] : null;
}
/**
* Returns the all the groups message belongs to.
* @param int $namespace
* @param string $key
* @return string[] Possibly empty list of group ids.
*/
public static function messageKeyToGroups( $namespace, $key ) {
$mi = MessageIndex::singleton()->retrieve();
$normkey = self::normaliseKey( $namespace, $key );
if ( isset( $mi[$normkey] ) ) {
return (array)$mi[$normkey];
} else {
return [];
}
}
/**
* Converts page name and namespace to message index format.
* @param int $namespace
* @param string $key
* @return string
*/
public static function normaliseKey( $namespace, $key ) {
$key = lcfirst( $key );
return strtr( "$namespace:$key", ' ', '_' );
}
/**
* Constructs a fieldset with contents.
* @param string $legend Raw html.
* @param string $contents Raw html.
* @param array $attributes Html attributes for the fieldset.
* @return string Html.
*/
public static function fieldset( $legend, $contents, array $attributes = [] ) {
return Xml::openElement( 'fieldset', $attributes ) .
Xml::tags( 'legend', null, $legend ) . $contents .
Xml::closeElement( 'fieldset' );
}
/**
* Escapes the message, and does some mangling to whitespace, so that it is
* preserved when outputted as-is to html page. Line feeds are converted to
* \
and occurrences of leading and trailing and multiple consecutive
* spaces to non-breaking spaces.
*
* This is also implemented in JavaScript in ext.translate.quickedit.
*
* @param string $message Plain text string.
* @return string Text string that is ready for outputting.
*/
public static function convertWhiteSpaceToHTML( $message ) {
$msg = htmlspecialchars( $message );
$msg = preg_replace( '/^ /m', ' ', $msg );
$msg = preg_replace( '/ $/m', ' ', $msg );
$msg = preg_replace( '/ /', ' ', $msg );
$msg = str_replace( "\n", '
', $msg );
return $msg;
}
/**
* Construct the web address to given asset.
* @param string $path Path to the resource relative to extensions root directory.
* @return string Full or partial web path.
*/
public static function assetPath( $path ) {
global $wgExtensionAssetsPath;
// @phan-suppress-next-line PhanPossiblyUndeclaredVariable
return "$wgExtensionAssetsPath/Translate/$path";
}
/**
* Gets the path for cache files
* @param string $filename
* @return string Full path.
* @throws MWException If cache directory is not configured.
*/
public static function cacheFile( $filename ) {
global $wgTranslateCacheDirectory, $wgCacheDirectory;
if ( $wgTranslateCacheDirectory !== false ) {
$dir = $wgTranslateCacheDirectory;
} elseif ( $wgCacheDirectory !== false ) {
$dir = $wgCacheDirectory;
} else {
throw new MWException( "\$wgCacheDirectory must be configured" );
}
return "$dir/$filename";
}
/**
* Returns a random string that can be used as placeholder in strings.
* @return string
* @since 2012-07-31
*/
public static function getPlaceholder() {
static $i = 0;
return "\x7fUNIQ" . dechex( mt_rand( 0, 0x7fffffff ) ) .
dechex( mt_rand( 0, 0x7fffffff ) ) . '-' . $i++;
}
/**
* Get URLs for icons if available.
* @param MessageGroup $g
* @param int $size Length of the edge of a bounding box to fit the icon.
* @return null|array
* @since 2013-04-01
*/
public static function getIcon( MessageGroup $g, $size ) {
$icon = $g->getIcon();
if ( substr( $icon, 0, 7 ) !== 'wiki://' ) {
return null;
}
$formats = [];
$filename = substr( $icon, 7 );
$file = MediaWikiServices::getInstance()->getRepoGroup()->findFile( $filename );
if ( !$file ) {
wfWarn( "Unknown message group icon file $icon" );
return null;
}
if ( $file->isVectorized() ) {
$formats['vector'] = $file->getFullUrl();
}
$formats['raster'] = $file->createThumb( $size, $size );
return $formats;
}
/**
* Get a DB handle suitable for read and read-for-write cases
*
* @return \Wikimedia\Rdbms\IDatabase Primary for HTTP POST, CLI, DB already changed;
* replica otherwise
*/
public static function getSafeReadDB() {
$lb = MediaWikiServices::getInstance()->getDBLoadBalancer();
$index = self::shouldReadFromPrimary() ? DB_PRIMARY : DB_REPLICA;
return $lb->getConnectionRef( $index );
}
/**
* Check whether primary should be used for reads to avoid reading stale data.
*
* @return bool
*/
public static function shouldReadFromPrimary() {
$lb = MediaWikiServices::getInstance()->getDBLoadBalancer();
// Parsing APIs need POST for payloads but are read-only, so avoid spamming
// the primary then. No good way to check this at the moment...
if ( PageTranslationHooks::$renderingContext ) {
return false;
}
if ( method_exists( $lb, 'hasOrMadeRecentPrimaryChanges' ) ) {
// MW 1.37+
return PHP_SAPI === 'cli' ||
RequestContext::getMain()->getRequest()->wasPosted() ||
$lb->hasOrMadeRecentPrimaryChanges();
}
// MW >=1.36
return PHP_SAPI === 'cli' ||
RequestContext::getMain()->getRequest()->wasPosted() ||
$lb->hasOrMadeRecentMasterChanges();
}
/**
* Get an URL that points to an editor for this message handle.
* @param MessageHandle $handle
* @return string Domain relative URL
* @since 2017.10
*/
public static function getEditorUrl( MessageHandle $handle ) {
if ( !$handle->isValid() ) {
return $handle->getTitle()->getLocalURL( [ 'action' => 'edit' ] );
}
$title = MediaWikiServices::getInstance()
->getSpecialPageFactory()->getPage( 'Translate' )->getPageTitle();
return $title->getFullURL( [
'showMessage' => $handle->getInternalKey(),
'group' => $handle->getGroup()->getId(),
'language' => $handle->getCode(),
] );
}
/**
* Serialize the given value
* @param mixed $value
* @return string
*/
public static function serialize( $value ) {
return serialize( $value );
}
/**
* Deserialize the given string
* @param string $str
* @param array|null $opts
* @return mixed
*/
public static function deserialize( $str, $opts = [ 'allowed_classes' => false ] ) {
return unserialize( $str, $opts );
}
/**
* @return string
* @since 2020.05
*/
public static function getVersion(): string {
// Avoid parsing JSON multiple time per request
static $version = null;
if ( $version === null ) {
$version = json_decode( file_get_contents( __DIR__ . '/extension.json' ) )->version;
}
return $version;
}
/**
* Checks if the namespace that the title belongs to allows subpages
*
* @internal - For internal use only
* @param Title $title
* @return bool
*/
public static function allowsSubpages( Title $title ): bool {
$mwInstance = MediaWikiServices::getInstance();
$namespaceInfo = $mwInstance->getNamespaceInfo();
return $namespaceInfo->hasSubpages( $title->getNamespace() );
}
public static function isEditPage( WebRequest $request ): bool {
$veAction = $request->getVal( 'veaction' );
if ( $veAction ) {
return true;
}
$action = $request->getVal( 'action' );
return $action === 'edit';
}
/**
* Wrapper around WikiPage::doEditContent() or WikiPage::doUserEditContent(),
* depending on the version. The Translate extenion supports MediaWiki 1.35+
* but ::doUserEditContent() is only available in 1.36. Once only 1.36+ is
* required this should be replaced with using ::doUserEditContent() directly
*
* @see WikiPage::doEditContent()
* @see WikiPage::doUserEditContent()
*
* The defaults for the parameters here match the defaults for both of the
* WikiPage methods, and the parameter order here matches that of
* ::doUserEditContent(). This function requires a Authority even though
* ::doEditContent() does not, because that falls back to the global $wgUser.
*
* @param WikiPage $wikiPage
* @param Content $content
* @param Authority $user for versions before 1.36 when Authority didn't exist, use a
* User object
* @param string|CommentStoreComment $summary
* @param int $flags
* @param bool|int $originalRevId
* @param array|null $tags
* @param int $undidRevId
* @return Status
*/
public static function doPageEdit(
WikiPage $wikiPage,
Content $content,
$user,
$summary,
$flags = 0,
$originalRevId = false,
$tags = [],
$undidRevId = 0
) {
if ( method_exists( $wikiPage, 'doUserEditContent' ) ) {
// MW 1.36+
return $wikiPage->doUserEditContent(
$content,
$user,
$summary,
$flags,
$originalRevId,
$tags,
$undidRevId
);
}
// MW 1.35
// Note: doEditContent() has an extra parameter, $serialFormat, but that has been
// ignored since 1.32.
return $wikiPage->doEditContent(
$content,
$summary,
$flags,
$originalRevId,
$user,
null, // $serialFormat
$tags,
$undidRevId
);
}
/**
* Checks whether a language code is supported for translation at the wiki level.
* Note that it is possible that message groups define other language codes which
* are not supported by the wiki, in which case this function would return false
* for those.
*
* @param string $code
* @return bool
*/
public static function isSupportedLanguageCode( string $code ): bool {
$all = self::getLanguageNames( null );
return isset( $all[ $code ] );
}
}