From: daniel Date: Thu, 14 Jun 2012 10:43:43 +0000 (+0200) Subject: Merge branch 'master' of ssh://gerrit.wikimedia.org:29418/mediawiki/core into Wikidata X-Git-Tag: 1.31.0-rc.0~22097^2^2~97 X-Git-Url: https://git.cyclocoop.org/%27.WWW_URL.%27admin/?a=commitdiff_plain;h=a830943203e156c4aa242e0f931c1dac0f00925c;hp=c15d0a7521231c2cb71e664265e08d0ae514fc73;p=lhc%2Fweb%2Fwiklou.git Merge branch 'master' of ssh://gerrit.wikimedia.org:29418/mediawiki/core into Wikidata --- diff --git a/.gitreview b/.gitreview index 0ec44b8359..3b2aa76b2c 100644 --- a/.gitreview +++ b/.gitreview @@ -2,5 +2,5 @@ host=gerrit.wikimedia.org port=29418 project=mediawiki/core.git -defaultbranch=master +defaultbranch=Wikidata defaultrebase=0 diff --git a/bin/svnstat b/bin/svnstat old mode 100755 new mode 100644 diff --git a/bin/ulimit-tvf.sh b/bin/ulimit-tvf.sh old mode 100755 new mode 100644 diff --git a/bin/ulimit4.sh b/bin/ulimit4.sh old mode 100755 new mode 100644 diff --git a/docs/hooks.txt b/docs/hooks.txt index a57d1d2aee..f0ba9d7ad5 100644 --- a/docs/hooks.txt +++ b/docs/hooks.txt @@ -406,9 +406,14 @@ token types. used to retrieve this type of tokens. 'ArticleAfterFetchContent': after fetching content of an article from +the database. DEPRECATED, use ArticleAfterFetchContentObject instead. +$article: the article (object) being loaded from the database +&$content: the content (string) of the article + +'ArticleAfterFetchContentObject': after fetching content of an article from the database $article: the article (object) being loaded from the database -$content: the content (string) of the article +&$content: the content of the article, as a Content object 'ArticleConfirmDelete': before writing the confirmation form for article deletion @@ -454,7 +459,7 @@ Wiki::articleFromTitle() $title: title (object) used to create the article object $article: article (object) that will be returned -'ArticleInsertComplete': After a new article is created +'ArticleInsertComplete': After a new article is created. DEPRECATED, use ArticleContentInsertComplete $article: WikiPage created $user: User creating the article $text: New content @@ -465,6 +470,17 @@ $section: (No longer used) $flags: Flags passed to Article::doEdit() $revision: New Revision of the article +'ArticleContentInsertComplete': After a new article is created +$article: WikiPage created +$user: User creating the article +$content: New content as a Content object +$summary: Edit summary/comment +$isMinor: Whether or not the edit was marked as minor +$isWatch: (No longer used) +$section: (No longer used) +$flags: Flags passed to Article::doEdit() +$revision: New Revision of the article + 'ArticleMergeComplete': after merging to article using Special:Mergehistory $targetTitle: target title (object) $destTitle: destination title (object) @@ -513,7 +529,7 @@ $user: the user who did the rollback $revision: the revision the page was reverted back to $current: the reverted revision -'ArticleSave': before an article is saved +'ArticleSave': before an article is saved. DEPRECATED, use ArticleContentSave instead $article: the WikiPage (object) being saved $user: the user (object) saving the article $text: the new article text @@ -522,7 +538,16 @@ $isminor: minor flag $iswatch: watch flag $section: section # -'ArticleSaveComplete': After an article has been updated +'ArticleContentSave': before an article is saved. +$article: the WikiPage (object) being saved +$user: the user (object) saving the article +$content: the new article content, as a Content object +$summary: the article summary (comment) +$isminor: minor flag +$iswatch: watch flag +$section: section # + +'ArticleSaveComplete': After an article has been updated. DEPRECATED, use ArticleContentSaveComplete instead. $article: WikiPage modified $user: User performing the modification $text: New content @@ -535,6 +560,19 @@ $revision: New Revision of the article $status: Status object about to be returned by doEdit() $baseRevId: the rev ID (or false) this edit was based on +'ArticleContentSaveComplete': After an article has been updated +$article: WikiPage modified +$user: User performing the modification +$content: New content, as a Content object +$summary: Edit summary/comment +$isMinor: Whether or not the edit was marked as minor +$isWatch: (No longer used) +$section: (No longer used) +$flags: Flags passed to Article::doEdit() +$revision: New Revision of the article +$status: Status object about to be returned by doEdit() +$baseRevId: the rev ID (or false) this edit was based on + 'ArticleUndelete': When one or more revisions of an article are restored $title: Title corresponding to the article restored $create: Whether or not the restoration caused the page to be created @@ -561,11 +599,19 @@ object to both indicate that the output is done and what parser output was used. follwed an redirect $article: target article (object) -'ArticleViewCustom': allows to output the text of the article in a different format than wikitext +'ArticleViewCustom': allows to output the text of the article in a different format than wikitext. +DEPRECATED, use ArticleContentViewCustom instead. +Note that it is preferrable to implement proper handing for a custom data type using the ContentHandler facility. $text: text of the page $title: title of the page $output: reference to $wgOut +'ArticleContentViewCustom': allows to output the text of the article in a different format than wikitext. +Note that it is preferrable to implement proper handing for a custom data type using the ContentHandler facility. +$content: content of the page, as a Content object +$title: title of the page +$output: reference to $wgOut + 'AuthPluginAutoCreate': Called when creating a local account for an user logged in from an external authentication method $user: User object created locally @@ -693,6 +739,16 @@ the collation given in $collationName. 'ConfirmEmailComplete': Called after a user's email has been confirmed successfully $user: user (object) whose email is being confirmed +'ContentHandlerDefaultModelFor': Called when the default content model is determiend +for a given title. May be used to assign a different model for that title. +$title: the Title in question +&$model: the model name. Use with CONTENT_MODEL_XXX constants. + +'ContentHandlerForModelID': Called when a ContentHandler is requested for a given +cointent model name, but no entry for that model exists in $wgContentHandlers. +$modeName: the requested content model name +&$handler: set this to a ContentHandler object, if desired. + 'ContribsPager::getQueryInfo': Before the contributions query is about to run &$pager: Pager object for contributions &$queryInfo: The query for the contribs Pager @@ -758,12 +814,19 @@ $section: Section being edited &$error: Error message to return $summary: Edit summary for page -'EditFilterMerged': Post-section-merge edit filter +'EditFilterMerged': Post-section-merge edit filter. +DEPRECATED, use EditFilterMergedContent instead. $editor: EditPage instance (object) $text: content of the edit box &$error: error message to return $summary: Edit summary for page +'EditFilterMergedContent': Post-section-merge edit filter +$editor: EditPage instance (object) +$content: content of the edit box, as a Content object +&$error: error message to return +$summary: Edit summary for page + 'EditFormPreloadText': Allows population of the edit form when creating new pages &$text: Text to preload with @@ -826,14 +889,28 @@ $title: title of page being edited &$msg: localization message name, overridable. Default is either 'copyrightwarning' or 'copyrightwarning2' 'EditPageGetDiffText': Allow modifying the wikitext that will be used in -"Show changes" +"Show changes". DEPRECATED. Use EditPageGetDiffContent instead. +Note that it is preferrable to implement diff handling for different data types using the ContentHandler facility. $editPage: EditPage object &$newtext: wikitext that will be used as "your version" -'EditPageGetPreviewText': Allow modifying the wikitext that will be previewed +'EditPageGetDiffContent': Allow modifying the wikitext that will be used in +"Show changes". +Note that it is preferrable to implement diff handling for different data types using the ContentHandler facility. +$editPage: EditPage object +&$newtext: wikitext that will be used as "your version" + +'EditPageGetPreviewText': Allow modifying the wikitext that will be previewed. +DEPRECATED. Use EditPageGetPreviewContent instead. +Note that it is preferrable to implement previews for different data types using the COntentHandler facility. $editPage: EditPage object &$toparse: wikitext that will be parsed +'EditPageGetPreviewContent': Allow modifying the wikitext that will be previewed. +Note that it is preferrable to implement previews for different data types using the COntentHandler facility. +$editPage: EditPage object +&$content: Content object to be previewed (may be replaced by hook function) + 'EditPageNoSuchSection': When a section edit request is given for an non-existent section &$editpage: The current EditPage object &$res: the HTML of the error text @@ -1657,7 +1734,8 @@ $query : Original query. 'ShowMissingArticle': Called when generating the output for a non-existent page $article: The article object corresponding to the page -'ShowRawCssJs': Customise the output of raw CSS and JavaScript in page views +'ShowRawCssJs': Customise the output of raw CSS and JavaScript in page views. +DEPRECATED, use the ContentHandler facility to handle CSS and JavaScript! $text: Text being shown $title: Title of the custom script/stylesheet page $output: Current OutputPage object @@ -2268,6 +2346,13 @@ One, and only one hook should set this, and return false. &$opts: Options to use for the query &$join: Join conditions +'WikiPageDeletionUpdates': manipulate the list of DataUpdates to be applied when + a page is deleted. Called in WikiPage::getDeletionUpdates(). + Note that updates specific to a content model should be provided by the + respective ContentHandler's getDeletionUpdates() method. +$page: the WikiPage +&$updates: the array of DataUpdate objects. Hook function may want to add to it. + 'wfShellWikiCmd': Called when generating a shell-escaped command line string to run a MediaWiki cli script. &$script: MediaWiki cli script path diff --git a/includes/Article.php b/includes/Article.php index fdf0820663..7c76806ed4 100644 --- a/includes/Article.php +++ b/includes/Article.php @@ -57,10 +57,17 @@ class Article extends Page { public $mParserOptions; /** - * Content of the revision we are working on + * Text of the revision we are working on * @var string $mContent */ - var $mContent; // !< + var $mContent; // !< #BC cruft + + /** + * Content of the revision we are working on + * @var Content + * @since 1.WD + */ + var $mContentObject; // !< /** * Is the content ($mContent) already loaded? @@ -231,9 +238,35 @@ class Article extends Page { * This function has side effects! Do not use this function if you * only want the real revision text if any. * + * @deprecated in 1.WD; use getContentObject() instead + * * @return string Return the text of this revision */ public function getContent() { + wfDeprecated( __METHOD__, '1.WD' ); + $content = $this->getContentObject(); + return ContentHandler::getContentText( $content ); + } + + /** + * Returns a Content object representing the pages effective display content, + * not necessarily the revision's content! + * + * Note that getContent/loadContent do not follow redirects anymore. + * If you need to fetch redirectable content easily, try + * the shortcut in WikiPage::getRedirectTarget() + * + * This function has side effects! Do not use this function if you + * only want the real revision text if any. + * + * @return Content Return the content of this revision + * + * @since 1.WD + * + * @todo: FIXME: this should really be protected, all callers should be changed to use WikiPage::getContent() instead. + */ + public function getContentObject() { + global $wgUser; wfProfileIn( __METHOD__ ); if ( $this->mPage->getID() === 0 ) { @@ -244,17 +277,19 @@ class Article extends Page { if ( $text === false ) { $text = ''; } + + $content = ContentHandler::makeContent( $text, $this->getTitle() ); } else { - $text = wfMsgExt( $this->getContext()->getUser()->isLoggedIn() ? 'noarticletext' : 'noarticletextanon', 'parsemag' ); + $content = new MessageContent( $wgUser->isLoggedIn() ? 'noarticletext' : 'noarticletextanon', null, 'parsemag' ); } wfProfileOut( __METHOD__ ); - return $text; + return $content; } else { - $this->fetchContent(); + $this->fetchContentObject(); wfProfileOut( __METHOD__ ); - return $this->mContent; + return $this->mContentObject; } } @@ -335,16 +370,54 @@ class Article extends Page { * Get text of an article from database * Does *NOT* follow redirects. * + * @protected + * @note this is really internal functionality that should really NOT be used by other functions. For accessing + * article content, use the WikiPage class, especially WikiBase::getContent(). However, a lot of legacy code + * uses this method to retrieve page text from the database, so the function has to remain public for now. + * * @return mixed string containing article contents, or false if null + * @deprecated in 1.WD, use WikiPage::getContent() instead */ - function fetchContent() { - if ( $this->mContentLoaded ) { + function fetchContent() { #BC cruft! + wfDeprecated( __METHOD__, '1.WD' ); + + if ( $this->mContentLoaded && $this->mContent ) { return $this->mContent; } wfProfileIn( __METHOD__ ); + $content = $this->fetchContentObject(); + + $this->mContent = ContentHandler::getContentText( $content ); #@todo: get rid of mContent everywhere! + wfRunHooks( 'ArticleAfterFetchContent', array( &$this, &$this->mContent ) ); #BC cruft, deprecated! + + wfProfileOut( __METHOD__ ); + + return $this->mContent; + } + + + /** + * Get text content object + * Does *NOT* follow redirects. + * TODO: when is this null? + * + * @note code that wants to retrieve page content from the database should use WikiPage::getContent(). + * + * @return Content|null + * + * @since 1.WD + */ + protected function fetchContentObject() { + if ( $this->mContentLoaded ) { + return $this->mContentObject; + } + + wfProfileIn( __METHOD__ ); + $this->mContentLoaded = true; + $this->mContent = null; $oldid = $this->getOldID(); @@ -352,7 +425,7 @@ class Article extends Page { # fails we'll have something telling us what we intended. $t = $this->getTitle()->getPrefixedText(); $d = $oldid ? wfMsgExt( 'missingarticle-rev', array( 'escape' ), $oldid ) : ''; - $this->mContent = wfMsgNoTrans( 'missing-article', $t, $d ) ; + $this->mContentObject = new MessageContent( 'missing-article', array($t, $d), array() ) ; // @todo: this isn't page content but a UI message. horrible. if ( $oldid ) { # $this->mRevision might already be fetched by getOldIDFromRequest() @@ -372,6 +445,7 @@ class Article extends Page { } $this->mRevision = $this->mPage->getRevision(); + if ( !$this->mRevision ) { wfDebug( __METHOD__ . " failed to retrieve current page, rev_id " . $this->mPage->getLatest() . "\n" ); wfProfileOut( __METHOD__ ); @@ -381,14 +455,14 @@ class Article extends Page { // @todo FIXME: Horrible, horrible! This content-loading interface just plain sucks. // We should instead work with the Revision object when we need it... - $this->mContent = $this->mRevision->getText( Revision::FOR_THIS_USER ); // Loads if user is allowed + $this->mContentObject = $this->mRevision->getContent( Revision::FOR_THIS_USER ); // Loads if user is allowed $this->mRevIdFetched = $this->mRevision->getId(); - wfRunHooks( 'ArticleAfterFetchContent', array( &$this, &$this->mContent ) ); + wfRunHooks( 'ArticleAfterFetchContentObject', array( &$this, &$this->mContentObject ) ); wfProfileOut( __METHOD__ ); - return $this->mContent; + return $this->mContentObject; } /** @@ -421,7 +495,7 @@ class Article extends Page { * @return Revision|null */ public function getRevisionFetched() { - $this->fetchContent(); + $this->fetchContentObject(); return $this->mRevision; } @@ -581,7 +655,7 @@ class Article extends Page { break; case 3: # This will set $this->mRevision if needed - $this->fetchContent(); + $this->fetchContentObject(); # Are we looking at an old revision if ( $oldid && $this->mRevision ) { @@ -605,18 +679,21 @@ class Article extends Page { wfDebug( __METHOD__ . ": showing CSS/JS source\n" ); $this->showCssOrJsPage(); $outputDone = true; - } elseif( !wfRunHooks( 'ArticleViewCustom', array( $this->mContent, $this->getTitle(), $outputPage ) ) ) { + } elseif( !wfRunHooks( 'ArticleContentViewCustom', array( $this->fetchContentObject(), $this->getTitle(), $outputPage ) ) ) { + # Allow extensions do their own custom view for certain pages + $outputDone = true; + } elseif( Hooks::isRegistered( 'ArticleViewCustom' ) && !wfRunHooks( 'ArticleViewCustom', array( $this->fetchContent(), $this->getTitle(), $outputPage ) ) ) { #FIXME: fetchContent() is deprecated! # Allow extensions do their own custom view for certain pages $outputDone = true; } else { - $text = $this->getContent(); - $rt = Title::newFromRedirectArray( $text ); + $content = $this->getContentObject(); + $rt = $content->getRedirectChain(); if ( $rt ) { wfDebug( __METHOD__ . ": showing redirect=no page\n" ); # Viewing a redirect page (e.g. with parameter redirect=no) $outputPage->addHTML( $this->viewRedirect( $rt ) ); # Parse just to get categories, displaytitle, etc. - $this->mParserOutput = $wgParser->parse( $text, $this->getTitle(), $parserOptions ); + $this->mParserOutput = $content->getParserOutput( $this->getTitle(), $oldid, $parserOptions, false ); $outputPage->addParserOutputNoText( $this->mParserOutput ); $outputDone = true; } @@ -626,8 +703,9 @@ class Article extends Page { # Run the parse, protected by a pool counter wfDebug( __METHOD__ . ": doing uncached parse\n" ); + // @todo: shouldn't we be passing $this->getPage() to PoolWorkArticleView instead of plain $this? $poolArticleView = new PoolWorkArticleView( $this, $parserOptions, - $this->getRevIdFetched(), $useParserCache, $this->getContent() ); + $this->getRevIdFetched(), $useParserCache, $this->getContentObject(), $this->getContext() ); if ( !$poolArticleView->execute() ) { $error = $poolArticleView->getError(); @@ -720,7 +798,9 @@ class Article extends Page { $unhide = $request->getInt( 'unhide' ) == 1; $oldid = $this->getOldID(); - $de = new DifferenceEngine( $this->getContext(), $oldid, $diff, $rcid, $purge, $unhide ); + $contentHandler = ContentHandler::getForTitle( $this->getTitle() ); + $de = $contentHandler->createDifferenceEngine( $this->getContext(), $oldid, $diff, $rcid, $purge, $unhide ); + // DifferenceEngine directly fetched the revision: $this->mRevIdFetched = $de->mNewid; $de->showDiffPage( $diffOnly ); @@ -738,22 +818,21 @@ class Article extends Page { * This is hooked by SyntaxHighlight_GeSHi to do syntax highlighting of these * page views. */ - protected function showCssOrJsPage() { - $dir = $this->getContext()->getLanguage()->getDir(); - $lang = $this->getContext()->getLanguage()->getCode(); + protected function showCssOrJsPage( $showCacheHint = true ) { + global $wgOut; - $outputPage = $this->getContext()->getOutput(); - $outputPage->wrapWikiMsg( "
\n$1\n
", - 'clearyourcache' ); + if ( $showCacheHint ) { + $dir = $this->getContext()->getLanguage()->getDir(); + $lang = $this->getContext()->getLanguage()->getCode(); + + $wgOut->wrapWikiMsg( "
\n$1\n
", + 'clearyourcache' ); + } // Give hooks a chance to customise the output - if ( wfRunHooks( 'ShowRawCssJs', array( $this->mContent, $this->getTitle(), $outputPage ) ) ) { - // Wrap the whole lot in a
 and don't parse
-			$m = array();
-			preg_match( '!\.(css|js)$!u', $this->getTitle()->getText(), $m );
-			$outputPage->addHTML( "
\n" );
-			$outputPage->addHTML( htmlspecialchars( $this->mContent ) );
-			$outputPage->addHTML( "\n
\n" ); + if ( !Hooks::isRegistered('ShowRawCssJs') || wfRunHooks( 'ShowRawCssJs', array( $this->fetchContent(), $this->getTitle(), $wgOut ) ) ) { #FIXME: fetchContent() is deprecated + $po = $this->mContentObject->getParserOutput( $this->getTitle() ); + $wgOut->addHTML( $po->getText() ); } } @@ -1383,7 +1462,13 @@ class Article extends Page { // Generate deletion reason $hasHistory = false; if ( !$reason ) { - $reason = $this->generateReason( $hasHistory ); + try { + $reason = $this->generateReason( $hasHistory ); + } catch (MWException $e) { + # if a page is horribly broken, we still want to be able to delete it. so be lenient about errors here. + wfDebug("Error while building auto delete summary: $e"); + $reason = ''; + } } // If the page has a history, insert a warning @@ -1890,7 +1975,9 @@ class Article extends Page { * @return mixed */ public function generateReason( &$hasHistory ) { - return $this->mPage->getAutoDeleteReason( $hasHistory ); + $title = $this->mPage->getTitle(); + $handler = ContentHandler::getForTitle( $title ); + return $handler->getAutoDeleteReason( $title, $hasHistory ); } // ****** B/C functions for static methods ( __callStatic is PHP>=5.3 ) ****** // @@ -1928,6 +2015,7 @@ class Article extends Page { * @param $newtext * @param $flags * @return string + * @deprecated since 1.WD, use ContentHandler::getAutosummary() instead */ public static function getAutosummary( $oldtext, $newtext, $flags ) { return WikiPage::getAutosummary( $oldtext, $newtext, $flags ); diff --git a/includes/AutoLoader.php b/includes/AutoLoader.php index d29c4e35b6..e8e0b931c8 100644 --- a/includes/AutoLoader.php +++ b/includes/AutoLoader.php @@ -280,6 +280,19 @@ $wgAutoloadLocalClasses = array( 'ZhClient' => 'includes/ZhClient.php', 'ZipDirectoryReader' => 'includes/ZipDirectoryReader.php', + # content handler + 'Content' => 'includes/Content.php', + 'AbstractContent' => 'includes/Content.php', + 'ContentHandler' => 'includes/ContentHandler.php', + 'CssContent' => 'includes/Content.php', + 'CssContentHandler' => 'includes/ContentHandler.php', + 'JavaScriptContent' => 'includes/Content.php', + 'JavaScriptContentHandler' => 'includes/ContentHandler.php', + 'MessageContent' => 'includes/Content.php', + 'TextContent' => 'includes/Content.php', + 'WikitextContent' => 'includes/Content.php', + 'WikitextContentHandler' => 'includes/ContentHandler.php', + # includes/actions 'CachedAction' => 'includes/actions/CachedAction.php', 'CreditsAction' => 'includes/actions/CreditsAction.php', @@ -322,6 +335,7 @@ $wgAutoloadLocalClasses = array( 'ApiFormatDump' => 'includes/api/ApiFormatDump.php', 'ApiFormatFeedWrapper' => 'includes/api/ApiFormatBase.php', 'ApiFormatJson' => 'includes/api/ApiFormatJson.php', + 'ApiFormatNone' => 'includes/api/ApiFormatNone.php', 'ApiFormatPhp' => 'includes/api/ApiFormatPhp.php', 'ApiFormatRaw' => 'includes/api/ApiFormatRaw.php', 'ApiFormatTxt' => 'includes/api/ApiFormatTxt.php', @@ -1001,6 +1015,14 @@ $wgAutoloadLocalClasses = array( 'TestFileIterator' => 'tests/testHelpers.inc', 'TestRecorder' => 'tests/testHelpers.inc', + # tests/phpunit + 'RevisionStorageTest' => 'tests/phpunit/includes/RevisionStorageTest.php', + 'WikiPageTest' => 'tests/phpunit/includes/WikiPageTest.php', + 'WikitextContentTest' => 'tests/phpunit/includes/WikitextContentTest.php', + 'JavascriptContentTest' => 'tests/phpunit/includes/JavascriptContentTest.php', + 'DummyContentHandlerForTesting' => 'tests/phpunit/includes/ContentHandlerTest.php', + 'DummyContentForTesting' => 'tests/phpunit/includes/ContentHandlerTest.php', + # tests/parser 'ParserTest' => 'tests/parser/parserTest.inc', 'ParserTestParserHook' => 'tests/parser/parserTestsParserHook.php', diff --git a/includes/Content.php b/includes/Content.php new file mode 100644 index 0000000000..7d211c189f --- /dev/null +++ b/includes/Content.php @@ -0,0 +1,1072 @@ +getContentHandler()->getDefaultFormat() + * + * @since WD.1 + * + * @return ContentHandler + */ + public function getDefaultFormat(); + + /** + * Convenience method that returns the list of serialization formats + * supported for the content model that this Content object uses. + * + * Shorthand for $this->getContentHandler()->getSupportedFormats() + * + * @since WD.1 + * + * @return Array of supported serialization formats + */ + public function getSupportedFormats(); + + /** + * Returns true if $format is a supported serialization format for this + * Content object, false if it isn't. + * + * Note that this should always return true if $format is null, because null + * stands for the default serialization. + * + * Shorthand for $this->getContentHandler()->isSupportedFormat( $format ) + * + * @since WD.1 + * + * @param $format string The format to check + * @return bool Whether the format is supported + */ + public function isSupportedFormat( $format ); + + /** + * Convenience method for serializing this Content object. + * + * Shorthand for $this->getContentHandler()->serializeContent( $this, $format ) + * + * @since WD.1 + * + * @param $format null|string The desired serialization format (or null for + * the default format). + * @return string Serialized form of this Content object + */ + public function serialize( $format = null ); + + /** + * Returns true if this Content object represents empty content. + * + * @since WD.1 + * + * @return bool Whether this Content object is empty + */ + public function isEmpty(); + + /** + * Returns whether the content is valid. This is intended for local validity + * checks, not considering global consistency. + * + * Content needs to be valid before it can be saved. + * + * This default implementation always returns true. + * + * @since WD.1 + * + * @return boolean + */ + public function isValid(); + + /** + * Returns true if this Content objects is conceptually equivalent to the + * given Content object. + * + * Contract: + * + * - Will return false if $that is null. + * - Will return true if $that === $this. + * - Will return false if $that->getModelName() != $this->getModel(). + * - Will return false if $that->getNativeData() is not equal to $this->getNativeData(), + * where the meaning of "equal" depends on the actual data model. + * + * Implementations should be careful to make equals() transitive and reflexive: + * + * - $a->equals( $b ) <=> $b->equals( $a ) + * - $a->equals( $b ) && $b->equals( $c ) ==> $a->equals( $c ) + * + * @since WD.1 + * + * @param $that Content The Content object to compare to + * @return bool True if this Content object is equal to $that, false otherwise. + */ + public function equals( Content $that = null ); + + /** + * Return a copy of this Content object. The following must be true for the + * object returned: + * + * if $copy = $original->copy() + * + * - get_class($original) === get_class($copy) + * - $original->getModel() === $copy->getModel() + * - $original->equals( $copy ) + * + * If and only if the Content object is immutable, the copy() method can and + * should return $this. That is, $copy === $original may be true, but only + * for immutable content objects. + * + * @since WD.1 + * + * @return Content. A copy of this object + */ + public function copy( ); + + /** + * Returns true if this content is countable as a "real" wiki page, provided + * that it's also in a countable location (e.g. a current revision in the + * main namespace). + * + * @since WD.1 + * + * @param $hasLinks Bool: If it is known whether this content contains + * links, provide this information here, to avoid redundant parsing to + * find out. + * @return boolean + */ + public function isCountable( $hasLinks = null ) ; + + /** + * Convenience method, shorthand for + * $this->getContentHandler()->getParserOutput( $this, $title, $revId, $options, $generateHtml ) + * + * @note: subclasses should NOT override this to provide custom rendering. + * Override ContentHandler::getParserOutput() instead! + * + * @param $title Title + * @param $revId null + * @param $options null|ParserOptions + * @param $generateHtml Boolean Whether to generate HTML (default: true). + * If false, the result of calling getText() on the ParserOutput object + * returned by this method is undefined. + * + * @since WD.1 + * + * @return ParserOutput + */ + public function getParserOutput( Title $title, $revId = null, ParserOptions $options = null, + $generateHtml = true ); + + /** + * Construct the redirect destination from this content and return an + * array of Titles, or null if this content doesn't represent a redirect. + * The last element in the array is the final destination after all redirects + * have been resolved (up to $wgMaxRedirects times). + * + * @since WD.1 + * + * @return Array of Titles, with the destination last + */ + public function getRedirectChain(); + + /** + * Construct the redirect destination from this content and return a Title, + * or null if this content doesn't represent a redirect. + * This will only return the immediate redirect target, useful for + * the redirect table and other checks that don't need full recursion. + * + * @since WD.1 + * + * @return Title: The corresponding Title + */ + public function getRedirectTarget(); + + /** + * Construct the redirect destination from this content and return the + * Title, or null if this content doesn't represent a redirect. + * + * This will recurse down $wgMaxRedirects times or until a non-redirect + * target is hit in order to provide (hopefully) the Title of the final + * destination instead of another redirect. + * + * There is usually no need to override the default behaviour, subclasses that + * want to implement redirects should override getRedirectTarget(). + * + * @since WD.1 + * + * @return Title + */ + public function getUltimateRedirectTarget(); + + /** + * Returns whether this Content represents a redirect. + * Shorthand for getRedirectTarget() !== null. + * + * @since WD.1 + * + * @return bool + */ + public function isRedirect(); + + /** + * Returns the section with the given ID. + * + * @since WD.1 + * + * @param $sectionId string The section's ID, given as a numeric string. + * The ID "0" retrieves the section before the first heading, "1" the + * text between the first heading (included) and the second heading + * (excluded), etc. + * @return Content|Boolean|null The section, or false if no such section + * exist, or null if sections are not supported. + */ + public function getSection( $sectionId ); + + /** + * Replaces a section of the content and returns a Content object with the + * section replaced. + * + * @since WD.1 + * + * @param $section Empty/null/false or a section number (0, 1, 2, T1, T2...), or "new" + * @param $with Content: new content of the section + * @param $sectionTitle String: new section's subject, only if $section is 'new' + * @return string Complete article text, or null if error + */ + public function replaceSection( $section, Content $with, $sectionTitle = '' ); + + /** + * Returns a Content object with pre-save transformations applied (or this + * object if no transformations apply). + * + * @since WD.1 + * + * @param $title Title + * @param $user User + * @param $popts null|ParserOptions + * @return Content + */ + public function preSaveTransform( Title $title, User $user, ParserOptions $popts ); + + /** + * Returns a new WikitextContent object with the given section heading + * prepended, if supported. The default implementation just returns this + * Content object unmodified, ignoring the section header. + * + * @since WD.1 + * + * @param $header string + * @return Content + */ + public function addSectionHeader( $header ); + + /** + * Returns a Content object with preload transformations applied (or this + * object if no transformations apply). + * + * @since WD.1 + * + * @param $title Title + * @param $popts null|ParserOptions + * @return Content + */ + public function preloadTransform( Title $title, ParserOptions $popts ); + + # TODO: handle ImagePage and CategoryPage + # TODO: make sure we cover lucene search / wikisearch. + # TODO: make sure ReplaceTemplates still works + # FUTURE: nice&sane integration of GeSHi syntax highlighting + # [11:59] Hooks are ugly; make CodeHighlighter interface and a + # config to set the class which handles syntax highlighting + # [12:00] And default it to a DummyHighlighter + + # TODO: make sure we cover the external editor interface (does anyone actually use that?!) + + # TODO: tie into API to provide contentModel for Revisions + # TODO: tie into API to provide serialized version and contentFormat for Revisions + # TODO: tie into API edit interface + # FUTURE: make EditForm plugin for EditPage + + # FUTURE: special type for redirects?! + # FUTURE: MultipartMultipart < WikipageContent (Main + Links + X) + # FUTURE: LinksContent < LanguageLinksContent, CategoriesContent + + // @TODO: add support for ar_content_format, ar_content_model, + // rev_content_format, rev_content_model to API +} + + +/** + * A content object represents page content, e.g. the text to show on a page. + * Content objects have no knowledge about how they relate to Wiki pages. + * + * @since 1.WD + */ +abstract class AbstractContent implements Content { + + /** + * Name of the content model this Content object represents. + * Use with CONTENT_MODEL_XXX constants + * + * @var string $model_id + */ + protected $model_id; + + /** + * @param $model_id int + */ + public function __construct( $model_id = null ) { + $this->model_id = $model_id; + } + + /** + * @see Content::getModel() + */ + public function getModel() { + return $this->model_id; + } + + /** + * Throws an MWException if $model_id is not the id of the content model + * supported by this Content object. + * + * @param $model_id int the model to check + * + * @throws MWException + */ + protected function checkModelID( $model_id ) { + if ( $model_id !== $this->model_id ) { + $model_name = ContentHandler::getContentModelName( $model_id ); + $own_model_name = ContentHandler::getContentModelName( $this->model_id ); + + throw new MWException( "Bad content model: " . + "expected {$this->model_id} ($own_model_name) " . + "but got $model_id ($model_name)." ); + } + } + + /** + * @see Content::getContentHandler() + */ + public function getContentHandler() { + return ContentHandler::getForContent( $this ); + } + + /** + * @see Content::getDefaultFormat() + */ + public function getDefaultFormat() { + return $this->getContentHandler()->getDefaultFormat(); + } + + /** + * @see Content::getSupportedFormats() + */ + public function getSupportedFormats() { + return $this->getContentHandler()->getSupportedFormats(); + } + + /** + * @see Content::isSupportedFormat() + */ + public function isSupportedFormat( $format ) { + if ( !$format ) { + return true; // this means "use the default" + } + + return $this->getContentHandler()->isSupportedFormat( $format ); + } + + /** + * Throws an MWException if $this->isSupportedFormat( $format ) doesn't + * return true. + * + * @param $format + * @throws MWException + */ + protected function checkFormat( $format ) { + if ( !$this->isSupportedFormat( $format ) ) { + throw new MWException( "Format $format is not supported for content model " . + $this->getModel() ); + } + } + + /** + * @see Content::serialize + */ + public function serialize( $format = null ) { + return $this->getContentHandler()->serializeContent( $this, $format ); + } + + /** + * @see Content::isEmpty() + */ + public function isEmpty() { + return $this->getSize() == 0; + } + + /** + * @see Content::isValid() + */ + public function isValid() { + return true; + } + + /** + * @see Content::equals() + */ + public function equals( Content $that = null ) { + if ( is_null( $that ) ) { + return false; + } + + if ( $that === $this ) { + return true; + } + + if ( $that->getModel() !== $this->getModel() ) { + return false; + } + + return $this->getNativeData() === $that->getNativeData(); + } + + /** + * @see Content::getParserOutput() + */ + public function getParserOutput( Title $title, $revId = null, ParserOptions $options = null, + $generateHtml = true ) + { + return $this->getContentHandler()->getParserOutput( + $this, $title, $revId, $options, $generateHtml ); + } + + /** + * @see Content::getRedirectChain() + */ + public function getRedirectChain() { + global $wgMaxRedirects; + $title = $this->getRedirectTarget(); + if ( is_null( $title ) ) { + return null; + } + // recursive check to follow double redirects + $recurse = $wgMaxRedirects; + $titles = array( $title ); + while ( --$recurse > 0 ) { + if ( $title->isRedirect() ) { + $page = WikiPage::factory( $title ); + $newtitle = $page->getRedirectTarget(); + } else { + break; + } + // Redirects to some special pages are not permitted + if ( $newtitle instanceOf Title && $newtitle->isValidRedirectTarget() ) { + // The new title passes the checks, so make that our current + // title so that further recursion can be checked + $title = $newtitle; + $titles[] = $newtitle; + } else { + break; + } + } + return $titles; + } + + /** + * @see Content::getRedirectTarget() + */ + public function getRedirectTarget() { + return null; + } + + /** + * @see Content::getUltimateRedirectTarget() + * @note: migrated here from Title::newFromRedirectRecurse + */ + public function getUltimateRedirectTarget() { + $titles = $this->getRedirectChain(); + return $titles ? array_pop( $titles ) : null; + } + + /** + * @since WD.1 + * + * @return bool + */ + public function isRedirect() { + return $this->getRedirectTarget() !== null; + } + + /** + * @see Content::getSection() + */ + public function getSection( $sectionId ) { + return null; + } + + /** + * @see Content::replaceSection() + */ + public function replaceSection( $section, Content $with, $sectionTitle = '' ) { + return null; + } + + /** + * @see Content::preSaveTransform() + */ + public function preSaveTransform( Title $title, User $user, ParserOptions $popts ) { + return $this; + } + + /** + * @see Content::addSectionHeader() + */ + public function addSectionHeader( $header ) { + return $this; + } + + /** + * @see Content::preloadTransform() + */ + public function preloadTransform( Title $title, ParserOptions $popts ) { + return $this; + } +} + +/** + * Content object implementation for representing flat text. + * + * TextContent instances are immutable + * + * @since WD.1 + */ +abstract class TextContent extends AbstractContent { + + public function __construct( $text, $model_id = null ) { + parent::__construct( $model_id ); + + $this->mText = $text; + } + + public function copy() { + return $this; # NOTE: this is ok since TextContent are immutable. + } + + public function getTextForSummary( $maxlength = 250 ) { + global $wgContLang; + + $text = $this->getNativeData(); + + $truncatedtext = $wgContLang->truncate( + preg_replace( "/[\n\r]/", ' ', $text ), + max( 0, $maxlength ) ); + + return $truncatedtext; + } + + /** + * returns the text's size in bytes. + * + * @return int The size + */ + public function getSize( ) { + $text = $this->getNativeData( ); + return strlen( $text ); + } + + /** + * Returns true if this content is not a redirect, and $wgArticleCountMethod + * is "any". + * + * @param $hasLinks Bool: if it is known whether this content contains links, + * provide this information here, to avoid redundant parsing to find out. + * + * @return bool True if the content is countable + */ + public function isCountable( $hasLinks = null ) { + global $wgArticleCountMethod; + + if ( $this->isRedirect( ) ) { + return false; + } + + if ( $wgArticleCountMethod === 'any' ) { + return true; + } + + return false; + } + + /** + * Returns the text represented by this Content object, as a string. + * + * @param the raw text + */ + public function getNativeData( ) { + $text = $this->mText; + return $text; + } + + /** + * Returns the text represented by this Content object, as a string. + * + * @param the raw text + */ + public function getTextForSearchIndex( ) { + return $this->getNativeData(); + } + + /** + * Returns the text represented by this Content object, as a string. + * + * @param the raw text + */ + public function getWikitextForTransclusion( ) { + return $this->getNativeData(); + } + + /** + * Diff this content object with another content object.. + * + * @since WD.diff + * + * @param $that Content the other content object to compare this content object to + * @param $lang Language the language object to use for text segmentation. + * If not given, $wgContentLang is used. + * + * @return DiffResult a diff representing the changes that would have to be + * made to this content object to make it equal to $that. + */ + public function diff( Content $that, Language $lang = null ) { + global $wgContLang; + + $this->checkModelID( $that->getModel() ); + + # @todo: could implement this in DifferenceEngine and just delegate here? + + if ( !$lang ) $lang = $wgContLang; + + $otext = $this->getNativeData(); + $ntext = $this->getNativeData(); + + # Note: Use native PHP diff, external engines don't give us abstract output + $ota = explode( "\n", $wgContLang->segmentForDiff( $otext ) ); + $nta = explode( "\n", $wgContLang->segmentForDiff( $ntext ) ); + + $diff = new Diff( $ota, $nta ); + return $diff; + } + + +} + +/** + * @since WD.1 + */ +class WikitextContent extends TextContent { + + public function __construct( $text ) { + parent::__construct( $text, CONTENT_MODEL_WIKITEXT ); + } + + /** + * @see Content::getSection() + */ + public function getSection( $section ) { + global $wgParser; + + $text = $this->getNativeData(); + $sect = $wgParser->getSection( $text, $section, false ); + + return new WikitextContent( $sect ); + } + + /** + * @see Content::replaceSection() + */ + public function replaceSection( $section, Content $with, $sectionTitle = '' ) { + wfProfileIn( __METHOD__ ); + + $myModelId = $this->getModel(); + $sectionModelId = $with->getModel(); + + if ( $sectionModelId != $myModelId ) { + $myModelName = ContentHandler::getContentModelName( $myModelId ); + $sectionModelName = ContentHandler::getContentModelName( $sectionModelId ); + + throw new MWException( "Incompatible content model for section: " . + "document uses $myModelId ($myModelName), " . + "section uses $sectionModelId ($sectionModelName)." ); + } + + $oldtext = $this->getNativeData(); + $text = $with->getNativeData(); + + if ( $section === '' ) { + return $with; # XXX: copy first? + } if ( $section == 'new' ) { + # Inserting a new section + if ( $sectionTitle ) { + $subject = wfMsgForContent( 'newsectionheaderdefaultlevel', $sectionTitle ) . "\n\n"; + } else { + $subject = ''; + } + if ( wfRunHooks( 'PlaceNewSection', array( $this, $oldtext, $subject, &$text ) ) ) { + $text = strlen( trim( $oldtext ) ) > 0 + ? "{$oldtext}\n\n{$subject}{$text}" + : "{$subject}{$text}"; + } + } else { + # Replacing an existing section; roll out the big guns + global $wgParser; + + $text = $wgParser->replaceSection( $oldtext, $section, $text ); + } + + $newContent = new WikitextContent( $text ); + + wfProfileOut( __METHOD__ ); + return $newContent; + } + + /** + * Returns a new WikitextContent object with the given section heading + * prepended. + * + * @param $header string + * @return Content + */ + public function addSectionHeader( $header ) { + $text = wfMsgForContent( 'newsectionheaderdefaultlevel', $header ) . "\n\n" . + $this->getNativeData(); + + return new WikitextContent( $text ); + } + + /** + * Returns a Content object with pre-save transformations applied using + * Parser::preSaveTransform(). + * + * @param $title Title + * @param $user User + * @param $popts ParserOptions + * @return Content + */ + public function preSaveTransform( Title $title, User $user, ParserOptions $popts ) { + global $wgParser; + + $text = $this->getNativeData(); + $pst = $wgParser->preSaveTransform( $text, $title, $user, $popts ); + + return new WikitextContent( $pst ); + } + + /** + * Returns a Content object with preload transformations applied (or this + * object if no transformations apply). + * + * @param $title Title + * @param $popts ParserOptions + * @return Content + */ + public function preloadTransform( Title $title, ParserOptions $popts ) { + global $wgParser; + + $text = $this->getNativeData(); + $plt = $wgParser->getPreloadText( $text, $title, $popts ); + + return new WikitextContent( $plt ); + } + + /** + * Implement redirect extraction for wikitext. + * + * @return null|Title + * + * @note: migrated here from Title::newFromRedirectInternal() + * + * @see Content::getRedirectTarget + * @see AbstractContent::getRedirectTarget + */ + public function getRedirectTarget() { + global $wgMaxRedirects; + if ( $wgMaxRedirects < 1 ) { + // redirects are disabled, so quit early + return null; + } + $redir = MagicWord::get( 'redirect' ); + $text = trim( $this->getNativeData() ); + if ( $redir->matchStartAndRemove( $text ) ) { + // Extract the first link and see if it's usable + // Ensure that it really does come directly after #REDIRECT + // Some older redirects included a colon, so don't freak about that! + $m = array(); + if ( preg_match( '!^\s*:?\s*\[{2}(.*?)(?:\|.*?)?\]{2}!', $text, $m ) ) { + // Strip preceding colon used to "escape" categories, etc. + // and URL-decode links + if ( strpos( $m[1], '%' ) !== false ) { + // Match behavior of inline link parsing here; + $m[1] = rawurldecode( ltrim( $m[1], ':' ) ); + } + $title = Title::newFromText( $m[1] ); + // If the title is a redirect to bad special pages or is invalid, return null + if ( !$title instanceof Title || !$title->isValidRedirectTarget() ) { + return null; + } + return $title; + } + } + return null; + } + + /** + * Returns true if this content is not a redirect, and this content's text + * is countable according to the criteria defined by $wgArticleCountMethod. + * + * @param $hasLinks Bool if it is known whether this content contains + * links, provide this information here, to avoid redundant parsing to + * find out. + * @param $title null|\Title + * + * @internal param \IContextSource $context context for parsing if necessary + * + * @return bool True if the content is countable + */ + public function isCountable( $hasLinks = null, Title $title = null ) { + global $wgArticleCountMethod; + + if ( $this->isRedirect( ) ) { + return false; + } + + $text = $this->getNativeData(); + + switch ( $wgArticleCountMethod ) { + case 'any': + return true; + case 'comma': + return strpos( $text, ',' ) !== false; + case 'link': + if ( $hasLinks === null ) { # not known, find out + if ( !$title ) { + $context = RequestContext::getMain(); + $title = $context->getTitle(); + } + + $po = $this->getParserOutput( $title, null, null, false ); + $links = $po->getLinks(); + $hasLinks = !empty( $links ); + } + + return $hasLinks; + } + + return false; + } + + public function getTextForSummary( $maxlength = 250 ) { + $truncatedtext = parent::getTextForSummary( $maxlength ); + + # clean up unfinished links + # XXX: make this optional? wasn't there in autosummary, but required for + # deletion summary. + $truncatedtext = preg_replace( '/\[\[([^\]]*)\]?$/', '$1', $truncatedtext ); + + return $truncatedtext; + } + +} + +/** + * @since WD.1 + */ +class MessageContent extends TextContent { + public function __construct( $msg_key, $params = null, $options = null ) { + # XXX: messages may be wikitext, html or plain text! and maybe even + # something else entirely. + parent::__construct( null, CONTENT_MODEL_WIKITEXT ); + + $this->mMessageKey = $msg_key; + + $this->mParameters = $params; + + if ( is_null( $options ) ) { + $options = array(); + } + elseif ( is_string( $options ) ) { + $options = array( $options ); + } + + $this->mOptions = $options; + } + + /** + * Returns the message as rendered HTML, using the options supplied to the + * constructor plus "parse". + * @param the message text, parsed + */ + public function getHtml( ) { + $opt = array_merge( $this->mOptions, array( 'parse' ) ); + + return wfMsgExt( $this->mMessageKey, $this->mParameters, $opt ); + } + + + /** + * Returns the message as raw text, using the options supplied to the + * constructor minus "parse" and "parseinline". + * + * @param the message text, unparsed. + */ + public function getNativeData( ) { + $opt = array_diff( $this->mOptions, array( 'parse', 'parseinline' ) ); + + return wfMsgExt( $this->mMessageKey, $this->mParameters, $opt ); + } + +} + +/** + * @since WD.1 + */ +class JavaScriptContent extends TextContent { + public function __construct( $text ) { + parent::__construct( $text, CONTENT_MODEL_JAVASCRIPT ); + } + + /** + * Returns a Content object with pre-save transformations applied using + * Parser::preSaveTransform(). + * + * @param Title $title + * @param User $user + * @param ParserOptions $popts + * @return Content + */ + public function preSaveTransform( Title $title, User $user, ParserOptions $popts ) { + global $wgParser; + // @todo: make pre-save transformation optional for script pages + // See bug #32858 + + $text = $this->getNativeData(); + $pst = $wgParser->preSaveTransform( $text, $title, $user, $popts ); + + return new JavaScriptContent( $pst ); + } + +} + +/** + * @since WD.1 + */ +class CssContent extends TextContent { + public function __construct( $text ) { + parent::__construct( $text, CONTENT_MODEL_CSS ); + } + + /** + * Returns a Content object with pre-save transformations applied using + * Parser::preSaveTransform(). + * + * @param $title Title + * @param $user User + * @param $popts ParserOptions + * @return Content + */ + public function preSaveTransform( Title $title, User $user, ParserOptions $popts ) { + global $wgParser; + // @todo: make pre-save transformation optional for script pages + + $text = $this->getNativeData(); + $pst = $wgParser->preSaveTransform( $text, $title, $user, $popts ); + + return new CssContent( $pst ); + } + +} diff --git a/includes/ContentHandler.php b/includes/ContentHandler.php new file mode 100644 index 0000000000..63fa47df48 --- /dev/null +++ b/includes/ContentHandler.php @@ -0,0 +1,1202 @@ +getNativeData(). + * + * If $content is not a TextContent object, the behavior of this method + * depends on the global $wgContentHandlerTextFallback: + * - If $wgContentHandlerTextFallback is 'fail' and $content is not a + * TextContent object, an MWException is thrown. + * - If $wgContentHandlerTextFallback is 'serialize' and $content is not a + * TextContent object, $content->serialize() is called to get a string + * form of the content. + * - If $wgContentHandlerTextFallback is 'ignore' and $content is not a + * TextContent object, this method returns null. + * - otherwise, the behaviour is undefined. + * + * @since WD.1 + * + * @static + * @param $content Content|null + * @return null|string the textual form of $content, if available + * @throws MWException if $content is not an instance of TextContent and + * $wgContentHandlerTextFallback was set to 'fail'. + */ + public static function getContentText( Content $content = null ) { + global $wgContentHandlerTextFallback; + + if ( is_null( $content ) ) { + return ''; + } + + if ( $content instanceof TextContent ) { + return $content->getNativeData(); + } + + if ( $wgContentHandlerTextFallback == 'fail' ) { + throw new MWException( + "Attempt to get text from Content with model " . + $content->getModel() + ); + } + + if ( $wgContentHandlerTextFallback == 'serialize' ) { + return $content->serialize(); + } + + return null; + } + + /** + * Convenience function for creating a Content object from a given textual + * representation. + * + * $text will be deserialized into a Content object of the model specified + * by $modelId (or, if that is not given, $title->getContentModel()) using + * the given format. + * + * @since WD.1 + * + * @static + * + * @param $text string the textual representation, will be + * unserialized to create the Content object + * @param $title null|Title the title of the page this text belongs to. + * Required if $modelId is not provided. + * @param $modelId null|string the model to deserialize to. If not provided, + * $title->getContentModel() is used. + * @param $format null|string the format to use for deserialization. If not + * given, the model's default format is used. + * + * @return Content a Content object representing $text + * + * @throw MWException if $model or $format is not supported or if $text can + * not be unserialized using $format. + */ + public static function makeContent( $text, Title $title = null, + $modelId = null, $format = null ) + { + if ( is_null( $modelId ) ) { + if ( is_null( $title ) ) { + throw new MWException( "Must provide a Title object or a content model ID." ); + } + + $modelId = $title->getContentModel(); + } + + $handler = ContentHandler::getForModelID( $modelId ); + return $handler->unserializeContent( $text, $format ); + } + + /** + * Returns the name of the default content model to be used for the page + * with the given title. + * + * Note: There should rarely be need to call this method directly. + * To determine the actual content model for a given page, use + * Title::getContentModel(). + * + * Which model is to be used by default for the page is determined based + * on several factors: + * - The global setting $wgNamespaceContentModels specifies a content model + * per namespace. + * - The hook DefaultModelFor may be used to override the page's default + * model. + * - Pages in NS_MEDIAWIKI and NS_USER default to the CSS or JavaScript + * model if they end in .js or .css, respectively. + * - Pages in NS_MEDIAWIKI default to the wikitext model otherwise. + * - The hook TitleIsCssOrJsPage may be used to force a page to use the CSS + * or JavaScript model if they end in .js or .css, respectively. + * - The hook TitleIsWikitextPage may be used to force a page to use the + * wikitext model. + * + * If none of the above applies, the wikitext model is used. + * + * Note: this is used by, and may thus not use, Title::getContentModel() + * + * @since WD.1 + * + * @static + * @param $title Title + * @return null|string default model name for the page given by $title + */ + public static function getDefaultModelFor( Title $title ) { + global $wgNamespaceContentModels; + + // NOTE: this method must not rely on $title->getContentModel() directly or indirectly, + // because it is used to initialize the mContentModel member. + + $ns = $title->getNamespace(); + + $ext = false; + $m = null; + $model = null; + + if ( !empty( $wgNamespaceContentModels[ $ns ] ) ) { + $model = $wgNamespaceContentModels[ $ns ]; + } + + // Hook can determine default model + if ( !wfRunHooks( 'ContentHandlerDefaultModelFor', array( $title, &$model ) ) ) { + if ( !is_null( $model ) ) { + return $model; + } + } + + // Could this page contain custom CSS or JavaScript, based on the title? + $isCssOrJsPage = NS_MEDIAWIKI == $ns && preg_match( '!\.(css|js)$!u', $title->getText(), $m ); + if ( $isCssOrJsPage ) { + $ext = $m[1]; + } + + // Hook can force JS/CSS + wfRunHooks( 'TitleIsCssOrJsPage', array( $title, &$isCssOrJsPage ) ); + + // Is this a .css subpage of a user page? + $isJsCssSubpage = NS_USER == $ns + && !$isCssOrJsPage + && preg_match( "/\\/.*\\.(js|css)$/", $title->getText(), $m ); + if ( $isJsCssSubpage ) { + $ext = $m[1]; + } + + // Is this wikitext, according to $wgNamespaceContentModels or the DefaultModelFor hook? + $isWikitext = is_null( $model ) || $model == CONTENT_MODEL_WIKITEXT; + $isWikitext = $isWikitext && !$isCssOrJsPage && !$isJsCssSubpage; + + // Hook can override $isWikitext + wfRunHooks( 'TitleIsWikitextPage', array( $title, &$isWikitext ) ); + + if ( !$isWikitext ) { + switch ( $ext ) { + case 'js': + return CONTENT_MODEL_JAVASCRIPT; + case 'css': + return CONTENT_MODEL_CSS; + default: + return is_null( $model ) ? CONTENT_MODEL_TEXT : $model; + } + } + + // We established that it must be wikitext + + return CONTENT_MODEL_WIKITEXT; + } + + /** + * Returns the appropriate ContentHandler singleton for the given title. + * + * @since WD.1 + * + * @static + * @param $title Title + * @return ContentHandler + */ + public static function getForTitle( Title $title ) { + $modelId = $title->getContentModel(); + return ContentHandler::getForModelID( $modelId ); + } + + /** + * Returns the appropriate ContentHandler singleton for the given Content + * object. + * + * @since WD.1 + * + * @static + * @param $content Content + * @return ContentHandler + */ + public static function getForContent( Content $content ) { + $modelId = $content->getModel(); + return ContentHandler::getForModelID( $modelId ); + } + + /** + * @var Array A Cache of ContentHandler instances by model id + */ + static $handlers; + + /** + * Returns the ContentHandler singleton for the given model ID. Use the + * CONTENT_MODEL_XXX constants to identify the desired content model. + * + * ContentHandler singletons are taken from the global $wgContentHandlers + * array. Keys in that array are model names, the values are either + * ContentHandler singleton objects, or strings specifying the appropriate + * subclass of ContentHandler. + * + * If a class name is encountered when looking up the singleton for a given + * model name, the class is instantiated and the class name is replaced by + * the resulting singleton in $wgContentHandlers. + * + * If no ContentHandler is defined for the desired $modelId, the + * ContentHandler may be provided by the ContentHandlerForModelID hook. + * If no ContentHandler can be determined, an MWException is raised. + * + * @since WD.1 + * + * @static + * @param $modelId int The ID of the content model for which to get a + * handler. Use CONTENT_MODEL_XXX constants. + * @return ContentHandler The ContentHandler singleton for handling the + * model given by $modelId + * @throws MWException if no handler is known for $modelId. + */ + public static function getForModelID( $modelId ) { + global $wgContentHandlers; + + if ( isset( ContentHandler::$handlers[$modelId] ) ) { + return ContentHandler::$handlers[$modelId]; + } + + if ( empty( $wgContentHandlers[$modelId] ) ) { + $handler = null; + + wfRunHooks( 'ContentHandlerForModelID', array( $modelId, &$handler ) ); + + if ( $handler === null ) { + throw new MWException( "No handler for model #$modelId registered in \$wgContentHandlers" ); + } + + if ( !( $handler instanceof ContentHandler ) ) { + throw new MWException( "ContentHandlerForModelID must supply a ContentHandler instance" ); + } + } else { + $class = $wgContentHandlers[$modelId]; + $handler = new $class( $modelId ); + + if ( !( $handler instanceof ContentHandler ) ) { + throw new MWException( "$class from \$wgContentHandlers is not compatible with ContentHandler" ); + } + } + + ContentHandler::$handlers[$modelId] = $handler; + return ContentHandler::$handlers[$modelId]; + } + + /** + * Returns the appropriate MIME type for a given content format, + * or null if no MIME type is known for this format. + * + * MIME types can be registered in the global array $wgContentFormatMimeTypes. + * + * @static + * @param $id int The content format id, as given by a CONTENT_FORMAT_XXX + * constant or returned by Revision::getContentFormat(). + * + * @return string|null The content format's MIME type. + */ + public static function getContentFormatMimeType( $id ) { + global $wgContentFormatMimeTypes; + + if ( !isset( $wgContentFormatMimeTypes[ $id ] ) ) { + return null; + } + + return $wgContentFormatMimeTypes[ $id ]; + } + + /** + * Returns the content format if for a given MIME type, + * or null if no format ID if known for this MIME type. + * + * Mime types can be registered in the global array $wgContentFormatMimeTypes. + * + * @static + * @param $mime string the MIME type + * + * @return int|null The format ID, as defined by a CONTENT_FORMAT_XXX constant + */ + public static function getContentFormatID( $mime ) { + global $wgContentFormatMimeTypes; + + static $format_ids = null; + + if ( $format_ids === null ) { + $format_ids = array_flip( $wgContentFormatMimeTypes ); + } + + if ( !isset( $format_ids[ $mime ] ) ) { + return null; + } + + return $format_ids[ $mime ]; + } + + /** + * Returns the localized name for a given content model, + * or null if no MIME type is known. + * + * Model names are localized using system messages. Message keys + * have the form content-model-$id. + * + * @static + * @param $id int The content model ID, as given by a CONTENT_MODEL_XXX + * constant or returned by Revision::getContentModel(). + * + * @return string|null The content format's MIME type. + */ + public static function getContentModelName( $id ) { + $key = "content-model-$id"; + + if ( wfEmptyMsg( $key ) ) return null; + else return wfMsg( $key ); + } + + // ------------------------------------------------------------------------ + + protected $mModelID; + protected $mSupportedFormats; + + /** + * Constructor, initializing the ContentHandler instance with its model ID + * and a list of supported formats. Values for the parameters are typically + * provided as literals by subclass's constructors. + * + * @param $modelId int (use CONTENT_MODEL_XXX constants). + * @param $formats array List for supported serialization formats + * (typically as MIME types) + */ + public function __construct( $modelId, $formats ) { + $this->mModelID = $modelId; + $this->mSupportedFormats = $formats; + } + + + /** + * Serializes a Content object of the type supported by this ContentHandler. + * + * @since WD.1 + * + * @abstract + * @param $content Content The Content object to serialize + * @param $format null The desired serialization format + * @return string Serialized form of the content + */ + public abstract function serializeContent( Content $content, $format = null ); + + /** + * Unserializes a Content object of the type supported by this ContentHandler. + * + * @since WD.1 + * + * @abstract + * @param $blob string serialized form of the content + * @param $format null the format used for serialization + * @return Content the Content object created by deserializing $blob + */ + public abstract function unserializeContent( $blob, $format = null ); + + /** + * Creates an empty Content object of the type supported by this + * ContentHandler. + * + * @since WD.1 + * + * @return Content + */ + public abstract function makeEmptyContent(); + + /** + * Returns the model id that identifies the content model this + * ContentHandler can handle. Use with the CONTENT_MODEL_XXX constants. + * + * @since WD.1 + * + * @return int The model ID + */ + public function getModelID() { + return $this->mModelID; + } + + /** + * Throws an MWException if $model_id is not the ID of the content model + * supported by this ContentHandler. + * + * @since WD.1 + * + * @param $model_id int The model to check + * + * @throws MWException + */ + protected function checkModelID( $model_id ) { + if ( $model_id !== $this->mModelID ) { + $model_name = ContentHandler::getContentModelName( $model_id ); + $own_model_name = ContentHandler::getContentModelName( $this->mModelID ); + + throw new MWException( "Bad content model: " . + "expected {$this->mModelID} ($own_model_name) " . + "but got $model_id ($model_name)." ); + } + } + + /** + * Returns a list of serialization formats supported by the + * serializeContent() and unserializeContent() methods of this + * ContentHandler. + * + * @since WD.1 + * + * @return array of serialization formats as MIME type like strings + */ + public function getSupportedFormats() { + return $this->mSupportedFormats; + } + + /** + * The format used for serialization/deserialization by default by this + * ContentHandler. + * + * This default implementation will return the first element of the array + * of formats that was passed to the constructor. + * + * @since WD.1 + * + * @return string the name of the default serialization format as a MIME type + */ + public function getDefaultFormat() { + return $this->mSupportedFormats[0]; + } + + /** + * Returns true if $format is a serialization format supported by this + * ContentHandler, and false otherwise. + * + * Note that if $format is null, this method always returns true, because + * null means "use the default format". + * + * @since WD.1 + * + * @param $format string the serialization format to check + * @return bool + */ + public function isSupportedFormat( $format ) { + + if ( !$format ) { + return true; // this means "use the default" + } + + return in_array( $format, $this->mSupportedFormats ); + } + + /** + * Throws an MWException if isSupportedFormat( $format ) is not true. + * Convenient for checking whether a format provided as a parameter is + * actually supported. + * + * @param $format string the serialization format to check + * + * @throws MWException + */ + protected function checkFormat( $format ) { + if ( !$this->isSupportedFormat( $format ) ) { + throw new MWException( + "Format $format is not supported for content model " + . $this->getModelID() + ); + } + } + + /** + * Returns true if the content is consistent with the database, that is if + * saving it to the database would not violate any global constraints. + * + * Content needs to be valid using this method before it can be saved. + * + * This default implementation always returns true. + * + * @since WD.1 + * + * @param $content \Content + * + * @return boolean + */ + public function isConsistentWithDatabase( Content $content ) { + return true; + } + + /** + * Returns overrides for action handlers. + * Classes listed here will be used instead of the default one when + * (and only when) $wgActions[$action] === true. This allows subclasses + * to override the default action handlers. + * + * @since WD.1 + * + * @return Array + */ + public function getActionOverrides() { + return array(); + } + + /** + * Factory for creating an appropriate DifferenceEngine for this content model. + * + * @since WD.1 + * + * @param $context IContextSource context to use, anything else will be + * ignored + * @param $old Integer Old ID we want to show and diff with. + * @param $new int|string String either 'prev' or 'next'. + * @param $rcid Integer ??? FIXME (default 0) + * @param $refreshCache boolean If set, refreshes the diff cache + * @param $unhide boolean If set, allow viewing deleted revs + * + * @return DifferenceEngine + */ + public function createDifferenceEngine( IContextSource $context, + $old = 0, $new = 0, + $rcid = 0, # FIXME: use everywhere! + $refreshCache = false, $unhide = false + ) { + $this->checkModelID( $context->getTitle()->getContentModel() ); + + $diffEngineClass = $this->getDiffEngineClass(); + + return new $diffEngineClass( $context, $old, $new, $rcid, $refreshCache, $unhide ); + } + + /** + * Returns the name of the diff engine to use. + * + * @since WD.1 + * + * @return string + */ + protected function getDiffEngineClass() { + return 'DifferenceEngine'; + } + + /** + * Attempts to merge differences between three versions. + * Returns a new Content object for a clean merge and false for failure or + * a conflict. + * + * This default implementation always returns false. + * + * @since WD.1 + * + * @param $oldContent Content|string String + * @param $myContent Content|string String + * @param $yourContent Content|string String + * + * @return Content|Bool + */ + public function merge3( Content $oldContent, Content $myContent, Content $yourContent ) { + return false; + } + + /** + * Return an applicable auto-summary if one exists for the given edit. + * + * @since WD.1 + * + * @param $oldContent Content|null: the previous text of the page. + * @param $newContent Content|null: The submitted text of the page. + * @param $flags int Bit mask: a bit mask of flags submitted for the edit. + * + * @return string An appropriate auto-summary, or an empty string. + */ + public function getAutosummary( Content $oldContent = null, Content $newContent = null, $flags ) { + global $wgContLang; + + // Decide what kind of auto-summary is needed. + + // Redirect auto-summaries + + /** + * @var $ot Title + * @var $rt Title + */ + + $ot = !is_null( $oldContent ) ? $oldContent->getRedirectTarget() : null; + $rt = !is_null( $newContent ) ? $newContent->getRedirectTarget() : null; + + if ( is_object( $rt ) ) { + if ( !is_object( $ot ) + || !$rt->equals( $ot ) + || $ot->getFragment() != $rt->getFragment() ) + { + $truncatedtext = $newContent->getTextForSummary( + 250 + - strlen( wfMsgForContent( 'autoredircomment' ) ) + - strlen( $rt->getFullText() ) ); + + return wfMsgForContent( 'autoredircomment', $rt->getFullText(), $truncatedtext ); + } + } + + // New page auto-summaries + if ( $flags & EDIT_NEW && $newContent->getSize() > 0 ) { + // If they're making a new article, give its text, truncated, in + // the summary. + + $truncatedtext = $newContent->getTextForSummary( + 200 - strlen( wfMsgForContent( 'autosumm-new' ) ) ); + + return wfMsgForContent( 'autosumm-new', $truncatedtext ); + } + + // Blanking auto-summaries + if ( !empty( $oldContent ) && $oldContent->getSize() > 0 && $newContent->getSize() == 0 ) { + return wfMsgForContent( 'autosumm-blank' ); + } elseif ( !empty( $oldContent ) + && $oldContent->getSize() > 10 * $newContent->getSize() + && $newContent->getSize() < 500 ) + { + // Removing more than 90% of the article + + $truncatedtext = $newContent->getTextForSummary( + 200 - strlen( wfMsgForContent( 'autosumm-replace' ) ) ); + + return wfMsgForContent( 'autosumm-replace', $truncatedtext ); + } + + // If we reach this point, there's no applicable auto-summary for our + // case, so our auto-summary is empty. + + return ''; + } + + /** + * Auto-generates a deletion reason + * + * @since WD.1 + * + * @param $title Title: the page's title + * @param &$hasHistory Boolean: whether the page has a history + * @return mixed String containing deletion reason or empty string, or + * boolean false if no revision occurred + * + * @XXX &$hasHistory is extremely ugly, it's here because + * WikiPage::getAutoDeleteReason() and Article::getReason() + * have it / want it. + */ + public function getAutoDeleteReason( Title $title, &$hasHistory ) { + $dbw = wfGetDB( DB_MASTER ); + + // Get the last revision + $rev = Revision::newFromTitle( $title ); + + if ( is_null( $rev ) ) { + return false; + } + + // Get the article's contents + $content = $rev->getContent(); + $blank = false; + + $this->checkModelID( $content->getModel() ); + + // If the page is blank, use the text from the previous revision, + // which can only be blank if there's a move/import/protect dummy + // revision involved + if ( $content->getSize() == 0 ) { + $prev = $rev->getPrevious(); + + if ( $prev ) { + $content = $prev->getContent(); + $blank = true; + } + } + + // Find out if there was only one contributor + // Only scan the last 20 revisions + $res = $dbw->select( 'revision', 'rev_user_text', + array( + 'rev_page' => $title->getArticleID(), + $dbw->bitAnd( 'rev_deleted', Revision::DELETED_USER ) . ' = 0' + ), + __METHOD__, + array( 'LIMIT' => 20 ) + ); + + if ( $res === false ) { + // This page has no revisions, which is very weird + return false; + } + + $hasHistory = ( $res->numRows() > 1 ); + $row = $dbw->fetchObject( $res ); + + if ( $row ) { // $row is false if the only contributor is hidden + $onlyAuthor = $row->rev_user_text; + // Try to find a second contributor + foreach ( $res as $row ) { + if ( $row->rev_user_text != $onlyAuthor ) { // Bug 22999 + $onlyAuthor = false; + break; + } + } + } else { + $onlyAuthor = false; + } + + // Generate the summary with a '$1' placeholder + if ( $blank ) { + // The current revision is blank and the one before is also + // blank. It's just not our lucky day + $reason = wfMsgForContent( 'exbeforeblank', '$1' ); + } else { + if ( $onlyAuthor ) { + $reason = wfMsgForContent( 'excontentauthor', '$1', $onlyAuthor ); + } else { + $reason = wfMsgForContent( 'excontent', '$1' ); + } + } + + if ( $reason == '-' ) { + // Allow these UI messages to be blanked out cleanly + return ''; + } + + // Max content length = max comment length - length of the comment (excl. $1) + $text = $content->getTextForSummary( 255 - ( strlen( $reason ) - 2 ) ); + + // Now replace the '$1' placeholder + $reason = str_replace( '$1', $text, $reason ); + + return $reason; + } + + /** + * Parse the Content object and generate a ParserOutput from the result. + * $result->getText() can be used to obtain the generated HTML. If no HTML + * is needed, $generateHtml can be set to false; in that case, + * $result->getText() may return null. + * + * @param $content Content the content to render + * @param $title Title The page title to use as a context for rendering + * @param $revId null|int The revision being rendered (optional) + * @param $options null|ParserOptions Any parser options + * @param $generateHtml Boolean Whether to generate HTML (default: true). If false, + * the result of calling getText() on the ParserOutput object returned by + * this method is undefined. + * + * @since WD.1 + * + * @return ParserOutput + */ + public abstract function getParserOutput( Content $content, Title $title, + $revId = null, + ParserOptions $options = null, $generateHtml = true ); + # TODO: make RenderOutput and RenderOptions base classes + + /** + * Returns a list of DataUpdate objects for recording information about this + * Content in some secondary data store. If the optional second argument, + * $old, is given, the updates may model only the changes that need to be + * made to replace information about the old content with information about + * the new content. + * + * This default implementation calls + * $this->getParserOutput( $content, $title, null, null, false ), + * and then calls getSecondaryDataUpdates( $title, $recursive ) on the + * resulting ParserOutput object. + * + * Subclasses may implement this to determine the necessary updates more + * efficiently, or make use of information about the old content. + * + * @param $content Content The content for determining the necessary updates + * @param $title Title The context for determining the necessary updates + * @param $old Content|null An optional Content object representing the + * previous content, i.e. the content being replaced by this Content + * object. + * @param $recursive boolean Whether to include recursive updates (default: + * false). + * @param $parserOutput ParserOutput|null Optional ParserOutput object. + * Provide if you have one handy, to avoid re-parsing of the content. + * + * @return Array. A list of DataUpdate objects for putting information + * about this content object somewhere. + * + * @since WD.1 + */ + public function getSecondaryDataUpdates( Content $content, Title $title, + Content $old = null, + $recursive = true, ParserOutput $parserOutput = null + ) { + if ( !$parserOutput ) { + $parserOutput = $this->getParserOutput( $content, $title, null, null, false ); + } + + return $parserOutput->getSecondaryDataUpdates( $title, $recursive ); + } + + + /** + * Get the Content object that needs to be saved in order to undo all revisions + * between $undo and $undoafter. Revisions must belong to the same page, + * must exist and must not be deleted. + * + * @since WD.1 + * + * @param $current Revision The current text + * @param $undo Revision The revision to undo + * @param $undoafter Revision Must be an earlier revision than $undo + * + * @return mixed String on success, false on failure + */ + public function getUndoContent( Revision $current, Revision $undo, Revision $undoafter ) { + $cur_content = $current->getContent(); + + if ( empty( $cur_content ) ) { + return false; // no page + } + + $undo_content = $undo->getContent(); + $undoafter_content = $undoafter->getContent(); + + $this->checkModelID( $cur_content->getModel() ); + $this->checkModelID( $undo_content->getModel() ); + $this->checkModelID( $undoafter_content->getModel() ); + + if ( $cur_content->equals( $undo_content ) ) { + // No use doing a merge if it's just a straight revert. + return $undoafter_content; + } + + $undone_content = $this->merge3( $undo_content, $undoafter_content, $cur_content ); + + return $undone_content; + } + + /** + * Returns true for content models that support caching using the + * ParserCache mechanism. See WikiPage::isParserCacheUser(). + * + * @since WD.1 + * + * @return bool + */ + public function isParserCacheSupported() { + return true; + } + + /** + * Returns a list of updates to perform when the given content is deleted. + * The necessary updates may be taken from the Content object, or depend on + * the current state of the database. + * + * @since WD.1 + * + * @param $content \Content the Content object for deletion + * @param $title \Title the title of the deleted page + * @param $parserOutput null|\ParserOutput optional parser output object + * for efficient access to meta-information about the content object. + * Provide if you have one handy. + * + * @return array A list of DataUpdate instances that will clean up the + * database after deletion. + */ + public function getDeletionUpdates( Content $content, Title $title, + ParserOutput $parserOutput = null ) + { + return array( + new LinksDeletionUpdate( $title ), + ); + } + + /** + * Returns true if this content model supports sections. + * + * This default implementation returns false. + * + * @return boolean whether sections are supported. + */ + public function supportsSections() { + return false; + } +} + +/** + * @since WD.1 + */ +abstract class TextContentHandler extends ContentHandler { + + public function __construct( $modelId, $formats ) { + parent::__construct( $modelId, $formats ); + } + + /** + * Returns the content's text as-is. + * + * @param $content Content + * @param $format string|null + * @return mixed + */ + public function serializeContent( Content $content, $format = null ) { + $this->checkFormat( $format ); + return $content->getNativeData(); + } + + /** + * Attempts to merge differences between three versions. Returns a new + * Content object for a clean merge and false for failure or a conflict. + * + * All three Content objects passed as parameters must have the same + * content model. + * + * This text-based implementation uses wfMerge(). + * + * @param $oldContent \Content|string String + * @param $myContent \Content|string String + * @param $yourContent \Content|string String + * + * @return Content|Bool + */ + public function merge3( Content $oldContent, Content $myContent, Content $yourContent ) { + $this->checkModelID( $oldContent->getModel() ); + $this->checkModelID( $myContent->getModel() ); + $this->checkModelID( $yourContent->getModel() ); + + $format = $this->getDefaultFormat(); + + $old = $this->serializeContent( $oldContent, $format ); + $mine = $this->serializeContent( $myContent, $format ); + $yours = $this->serializeContent( $yourContent, $format ); + + $ok = wfMerge( $old, $mine, $yours, $result ); + + if ( !$ok ) { + return false; + } + + if ( !$result ) { + return $this->makeEmptyContent(); + } + + $mergedContent = $this->unserializeContent( $result, $format ); + return $mergedContent; + } + + /** + * Returns a generic ParserOutput object, wrapping the HTML returned by + * getHtml(). + * + * @param $content Content The content to render + * @param $title Title Context title for parsing + * @param $revId int|null Revision ID (for {{REVISIONID}}) + * @param $options ParserOptions|null Parser options + * @param $generateHtml bool Whether or not to generate HTML + * + * @return ParserOutput representing the HTML form of the text + */ + public function getParserOutput( Content $content, Title $title, + $revId = null, + ParserOptions $options = null, $generateHtml = true + ) { + $this->checkModelID( $content->getModel() ); + + # Generic implementation, relying on $this->getHtml() + + if ( $generateHtml ) { + $html = $this->getHtml( $content ); + } else { + $html = ''; + } + + $po = new ParserOutput( $html ); + return $po; + } + + /** + * Generates an HTML version of the content, for display. Used by + * getParserOutput() to construct a ParserOutput object. + * + * This default implementation just calls getHighlightHtml(). Content + * models that have another mapping to HTML (as is the case for markup + * languages like wikitext) should override this method to generate the + * appropriate HTML. + * + * @param $content Content The content to render + * + * @return string An HTML representation of the content + */ + protected function getHtml( Content $content ) { + $this->checkModelID( $content->getModel() ); + + return $this->getHighlightHtml( $content ); + } + + /** + * Generates a syntax-highlighted version the content, as HTML. + * Used by the default implementation of getHtml(). + * + * @param $content Content the content to render + * + * @return string an HTML representation of the content's markup + */ + protected function getHighlightHtml( Content $content ) { + $this->checkModelID( $content->getModel() ); + + # TODO: make Highlighter interface, use highlighter here, if available + return htmlspecialchars( $content->getNativeData() ); + } + + +} + +/** + * @since WD.1 + */ +class WikitextContentHandler extends TextContentHandler { + + public function __construct( $modelId = CONTENT_MODEL_WIKITEXT ) { + parent::__construct( $modelId, array( CONTENT_FORMAT_WIKITEXT ) ); + } + + public function unserializeContent( $text, $format = null ) { + $this->checkFormat( $format ); + + return new WikitextContent( $text ); + } + + public function makeEmptyContent() { + return new WikitextContent( '' ); + } + + /** + * Returns a ParserOutput object resulting from parsing the content's text + * using $wgParser. + * + * @since WD.1 + * + * @param $content Content the content to render + * @param $title \Title + * @param $revId null + * @param $options null|ParserOptions + * @param $generateHtml bool + * + * @internal param \IContextSource|null $context + * @return ParserOutput representing the HTML form of the text + */ + public function getParserOutput( Content $content, Title $title, + $revId = null, + ParserOptions $options = null, $generateHtml = true + ) { + global $wgParser; + + $this->checkModelID( $content->getModel() ); + + if ( !$options ) { + $options = new ParserOptions(); + } + + $po = $wgParser->parse( $content->getNativeData(), $title, $options, true, true, $revId ); + return $po; + } + + protected function getHtml( Content $content ) { + throw new MWException( + "getHtml() not implemented for wikitext. " + . "Use getParserOutput()->getText()." + ); + } + + /** + * Returns true because wikitext supports sections. + * + * @return boolean whether sections are supported. + */ + public function supportsSections() { + return true; + } +} + +# XXX: make ScriptContentHandler base class, do highlighting stuff there? + +/** + * @since WD.1 + */ +class JavaScriptContentHandler extends TextContentHandler { + + public function __construct( $modelId = CONTENT_MODEL_JAVASCRIPT ) { + parent::__construct( $modelId, array( CONTENT_FORMAT_JAVASCRIPT ) ); + } + + public function unserializeContent( $text, $format = null ) { + $this->checkFormat( $format ); + + return new JavaScriptContent( $text ); + } + + public function makeEmptyContent() { + return new JavaScriptContent( '' ); + } + + protected function getHtml( Content $content ) { + $html = ""; + $html .= "
\n";
+		$html .= $this->getHighlightHtml( $content );
+		$html .= "\n
\n"; + + return $html; + } +} + +/** + * @since WD.1 + */ +class CssContentHandler extends TextContentHandler { + + public function __construct( $modelId = CONTENT_MODEL_CSS ) { + parent::__construct( $modelId, array( CONTENT_FORMAT_CSS ) ); + } + + public function unserializeContent( $text, $format = null ) { + $this->checkFormat( $format ); + + return new CssContent( $text ); + } + + public function makeEmptyContent() { + return new CssContent( '' ); + } + + + protected function getHtml( Content $content ) { + $html = ""; + $html .= "
\n";
+		$html .= $this->getHighlightHtml( $content );
+		$html .= "\n
\n"; + + return $html; + } +} diff --git a/includes/DefaultSettings.php b/includes/DefaultSettings.php index c2606ce826..9bc0fd24ed 100644 --- a/includes/DefaultSettings.php +++ b/includes/DefaultSettings.php @@ -669,6 +669,40 @@ $wgMediaHandlers = array( 'image/x-djvu' => 'DjVuHandler', // compat ); +/** + * Plugins for page content model handling. + * Each entry in the array maps a model id to a class name + */ +$wgContentHandlers = array( + CONTENT_MODEL_WIKITEXT => 'WikitextContentHandler', // the usual case + CONTENT_MODEL_JAVASCRIPT => 'JavaScriptContentHandler', // dumb version, no syntax highlighting + CONTENT_MODEL_CSS => 'CssContentHandler', // dumb version, no syntax highlighting + CONTENT_MODEL_TEXT => 'TextContentHandler', // dumb plain text in
+);
+
+/**
+ * Mime types for content formats.
+ * Each entry in the array maps a content format to a mime type.
+ *
+ * Extensions that define their own content formats can register
+ * the appropriate mime types in this array.
+ *
+ * Such extensions shall use content format IDs
+ * larger than 100 and register the ids they use at
+ * 
+ * to avoid conflicts with other extensions.
+ */
+$wgContentFormatMimeTypes = array(
+	CONTENT_FORMAT_WIKITEXT => 'text/x-wiki',
+	CONTENT_FORMAT_JAVASCRIPT => 'text/javascript',
+	CONTENT_FORMAT_CSS => 'text/css',
+	CONTENT_FORMAT_TEXT => 'text/plain',
+	CONTENT_FORMAT_HTML => 'text/html',
+	CONTENT_FORMAT_XML => 'application/xml',
+	CONTENT_FORMAT_JSON => 'application/json',
+	CONTENT_FORMAT_SERIALIZED => 'application/vnd.php.serialized',
+);
+
 /**
  * Resizing can be done using PHP's internal image libraries or using
  * ImageMagick or another third-party converter, e.g. GraphicMagick.
@@ -5869,6 +5903,31 @@ $wgSeleniumConfigFile = null;
 $wgDBtestuser = ''; //db user that has permission to create and drop the test databases only
 $wgDBtestpassword = '';
 
+/**
+ * Associative array mapping namespace IDs to the name of the content model pages in that namespace should have by
+ * default (use the CONTENT_MODEL_XXX constants). If no special content type is defined for a given namespace,
+ * pages in that namespace will  use the CONTENT_MODEL_WIKITEXT (except for the special case of JS and CS pages).
+ */
+$wgNamespaceContentModels = array();
+
+/**
+ * How to react if a plain text version of a non-text Content object is requested using ContentHandler::getContentText():
+ *
+ * * 'ignore': return null
+ * * 'fail': throw an MWException
+ * * 'serializeContent': serializeContent to default format
+ */
+$wgContentHandlerTextFallback = 'ignore';
+
+/**
+ * Compatibility switch for running ContentHandler code withoput a schema update.
+ * Set to false to disable use of the database fields introduced by the ContentHandler facility.
+ *
+ * @deprecated this is only here to allow code deployment without a database schema update on large sites.
+ *             get rid of it in the next version.
+ */
+$wgContentHandlerUseDB = true;
+
 /**
  * For really cool vim folding this needs to be at the end:
  * vim: foldmarker=@{,@} foldmethod=marker
diff --git a/includes/Defines.php b/includes/Defines.php
index d0f0c26b19..fdbda952ab 100644
--- a/includes/Defines.php
+++ b/includes/Defines.php
@@ -259,7 +259,7 @@ define( 'APCOND_BLOCKED', 8 );
 define( 'APCOND_ISBOT', 9 );
 /**@}*/
 
-/**
+/** @{
  * Protocol constants for wfExpandUrl()
  */
 define( 'PROTO_HTTP', 'http://' );
@@ -268,3 +268,40 @@ define( 'PROTO_RELATIVE', '//' );
 define( 'PROTO_CURRENT', null );
 define( 'PROTO_CANONICAL', 1 );
 define( 'PROTO_INTERNAL', 2 );
+/**@}*/
+
+/**@{
+ * Content model ids, used by Content and ContentHandler
+ *
+ * Extensions that define their own content models shall use IDs
+ * larger than 100 and register the ids they use at
+ * 
+ * to avoid conflicts with other extensions.
+ */
+define( 'CONTENT_MODEL_WIKITEXT', 1 );
+define( 'CONTENT_MODEL_JAVASCRIPT', 2 );
+define( 'CONTENT_MODEL_CSS', 3 );
+define( 'CONTENT_MODEL_TEXT', 4 );
+/**@}*/
+
+/**@{
+ * Content format ids, used by Content and ContentHandler.
+ * Use ContentHander::getFormatMimeType() to get the associated mime type.
+ * Register mime types in $wgContentFormatMimeTypes.
+ *
+ * Extensions that define their own content formats shall use IDs
+ * larger than 100 and register the ids they use at
+ * 
+ * to avoid conflicts with other extensions.
+ */
+define( 'CONTENT_FORMAT_WIKITEXT', 1 ); // wikitext
+define( 'CONTENT_FORMAT_JAVASCRIPT', 2 ); // for js pages
+define( 'CONTENT_FORMAT_CSS', 3 );  // for css pages
+define( 'CONTENT_FORMAT_TEXT', 4 ); // for future use, e.g. with some plain-html messages.
+define( 'CONTENT_FORMAT_HTML', 5 ); // for future use, e.g. with some plain-html messages.
+define( 'CONTENT_FORMAT_SERIALIZED', 11 ); // for future use with the api, and for use by extensions
+define( 'CONTENT_FORMAT_JSON', 12 ); // for future use with the api, and for use by extensions
+define( 'CONTENT_FORMAT_XML', 13 ); // for future use with the api, and for use by extensions
+/**@}*/
+
+
diff --git a/includes/EditPage.php b/includes/EditPage.php
index 9e337fde31..cc09d7ffaa 100644
--- a/includes/EditPage.php
+++ b/includes/EditPage.php
@@ -155,6 +155,11 @@ class EditPage {
 	 */
 	const AS_IMAGE_REDIRECT_LOGGED     = 234;
 
+	/**
+	 * Status: can't parse content
+	 */
+	const AS_PARSE_ERROR                = 240;
+
 	/**
 	 * HTML id and name for the beginning of the edit form.
 	 */
@@ -214,6 +219,7 @@ class EditPage {
 	var $textbox1 = '', $textbox2 = '', $summary = '', $nosummary = false;
 	var $edittime = '', $section = '', $sectiontitle = '', $starttime = '';
 	var $oldid = 0, $editintro = '', $scrolltop = null, $bot = true;
+	var $content_model = null, $content_format = null;
 
 	# Placeholders for text injection by hooks (must be HTML)
 	# extensions should take care to _append_ to the present value
@@ -225,7 +231,7 @@ class EditPage {
 	public $editFormTextBottom = '';
 	public $editFormTextAfterContent = '';
 	public $previewTextAfterContent = '';
-	public $mPreloadText = '';
+	public $mPreloadContent = null;
 
 	/* $didSave should be set to true whenever an article was succesfully altered. */
 	public $didSave = false;
@@ -239,6 +245,11 @@ class EditPage {
 	public function __construct( Article $article ) {
 		$this->mArticle = $article;
 		$this->mTitle = $article->getTitle();
+
+		$this->content_model = $this->mTitle->getContentModel();
+
+		$handler = ContentHandler::getForModelID( $this->content_model );
+		$this->content_format = $handler->getDefaultFormat(); #NOTE: should be overridden by format of actual revision
 	}
 
 	/**
@@ -450,10 +461,10 @@ class EditPage {
 			return;
 		}
 
-		$content = $this->getContent();
+		$content = $this->getContentObject();
 
 		# Use the normal message if there's nothing to display
-		if ( $this->firsttime && $content === '' ) {
+		if ( $this->firsttime && $content->isEmpty() ) {
 			$action = $this->mTitle->exists() ? 'edit' :
 				( $this->mTitle->isTalkPage() ? 'createtalk' : 'createpage' );
 			throw new PermissionsError( $action, $permErrors );
@@ -467,13 +478,14 @@ class EditPage {
 		# If the user made changes, preserve them when showing the markup
 		# (This happens when a user is blocked during edit, for instance)
 		if ( !$this->firsttime ) {
-			$content = $this->textbox1;
+			$text = $this->textbox1;
 			$wgOut->addWikiMsg( 'viewyourtext' );
 		} else {
+			$text = $content->serialize( $this->content_format );
 			$wgOut->addWikiMsg( 'viewsourcetext' );
 		}
 
-		$this->showTextbox( $content, 'wpTextbox1', array( 'readonly' ) );
+		$this->showTextbox( $text, 'wpTextbox1', array( 'readonly' ) );
 
 		$wgOut->addHTML( Html::rawElement( 'div', array( 'class' => 'templatesUsed' ),
 			Linker::formatTemplates( $this->getTemplates() ) ) );
@@ -679,7 +691,7 @@ class EditPage {
 		} else {
 			# Not a posted form? Start with nothing.
 			wfDebug( __METHOD__ . ": Not a posted form.\n" );
-			$this->textbox1     = '';
+			$this->textbox1     = ''; #FIXME: track content object
 			$this->summary      = '';
 			$this->sectiontitle = '';
 			$this->edittime     = '';
@@ -711,10 +723,17 @@ class EditPage {
 			}
 		}
 
+		$this->oldid = $request->getInt( 'oldid' );
+
 		$this->bot = $request->getBool( 'bot', true );
 		$this->nosummary = $request->getBool( 'nosummary' );
 
-		$this->oldid = $request->getInt( 'oldid' );
+		$content_handler = ContentHandler::getForTitle( $this->mTitle );
+		$this->content_model = $request->getText( 'model', $content_handler->getModelID() ); #may be overridden by revision
+		$this->content_format = $request->getText( 'format', $content_handler->getDefaultFormat() ); #may be overridden by revision
+
+		#TODO: check if the desired model is allowed in this namespace, and if a transition from the page's current model to the new model is allowed
+		#TODO: check if the desired content model supports the given content format!
 
 		$this->live = $request->getCheck( 'live' );
 		$this->editintro = $request->getText( 'editintro',
@@ -747,7 +766,10 @@ class EditPage {
 	function initialiseForm() {
 		global $wgUser;
 		$this->edittime = $this->mArticle->getTimestamp();
-		$this->textbox1 = $this->getContent( false );
+
+		$content = $this->getContentObject( false ); #TODO: track content object?!
+		$this->textbox1 = $content->serialize( $this->content_format );
+
 		// activate checkboxes if user wants them to be always active
 		# Sort out the "watch" checkbox
 		if ( $wgUser->getOption( 'watchdefault' ) ) {
@@ -776,33 +798,54 @@ class EditPage {
 	 * @param $def_text string
 	 * @return mixed string on success, $def_text for invalid sections
 	 * @private
+	 * @deprecated since 1.WD
 	 */
-	function getContent( $def_text = '' ) {
-		global $wgOut, $wgRequest, $wgParser;
+	function getContent( $def_text = false ) { #FIXME: deprecated, replace usage!
+		wfDeprecated( __METHOD__, '1.WD' );
+
+		if ( $def_text !== null && $def_text !== false && $def_text !== '' ) {
+			$def_content = ContentHandler::makeContent( $def_text, $this->getTitle() );
+		} else {
+			$def_content = false;
+		}
+
+		$content = $this->getContentObject( $def_content );
+
+		return $content->serialize( $this->content_format ); #XXX: really use serialized form? use ContentHandler::getContentText() instead?
+	}
+
+	private function getContentObject( $def_content = null ) { #FIXME: use this!
+		global $wgOut, $wgRequest;
 
 		wfProfileIn( __METHOD__ );
 
-		$text = false;
+		$content = false;
 
 		// For message page not locally set, use the i18n message.
 		// For other non-existent articles, use preload text if any.
 		if ( !$this->mTitle->exists() || $this->section == 'new' ) {
 			if ( $this->mTitle->getNamespace() == NS_MEDIAWIKI && $this->section != 'new' ) {
 				# If this is a system message, get the default text.
-				$text = $this->mTitle->getDefaultMessageText();
+				$msg = $this->mTitle->getDefaultMessageText();
+
+				$content = ContentHandler::makeContent( $msg, $this->mTitle );
 			}
-			if ( $text === false ) {
+			if ( $content === false ) {
 				# If requested, preload some text.
 				$preload = $wgRequest->getVal( 'preload',
 					// Custom preload text for new sections
 					$this->section === 'new' ? 'MediaWiki:addsection-preload' : '' );
-				$text = $this->getPreloadedText( $preload );
+
+				$content = $this->getPreloadedContent( $preload );
 			}
 		// For existing pages, get text based on "undo" or section parameters.
 		} else {
 			if ( $this->section != '' ) {
 				// Get section edit text (returns $def_text for invalid sections)
-				$text = $wgParser->getSection( $this->getOriginalContent(), $this->section, $def_text );
+				$orig = $this->getOriginalContent();
+				$content = $orig ? $orig->getSection( $this->section ) : null;
+
+				if ( !$content ) $content = $def_content;
 			} else {
 				$undoafter = $wgRequest->getInt( 'undoafter' );
 				$undo = $wgRequest->getInt( 'undo' );
@@ -818,15 +861,16 @@ class EditPage {
 
 					# Sanity check, make sure it's the right page,
 					# the revisions exist and they were not deleted.
-					# Otherwise, $text will be left as-is.
+					# Otherwise, $content will be left as-is.
 					if ( !is_null( $undorev ) && !is_null( $oldrev ) &&
 						$undorev->getPage() == $oldrev->getPage() &&
 						$undorev->getPage() == $this->mTitle->getArticleID() &&
 						!$undorev->isDeleted( Revision::DELETED_TEXT ) &&
 						!$oldrev->isDeleted( Revision::DELETED_TEXT ) ) {
 
-						$text = $this->mArticle->getUndoText( $undorev, $oldrev );
-						if ( $text === false ) {
+						$content = $this->mArticle->getUndoContent( $undorev, $oldrev );
+
+						if ( $content === false ) {
 							# Warn the user that something went wrong
 							$undoMsg = 'failure';
 						} else {
@@ -858,14 +902,14 @@ class EditPage {
 						wfMsgNoTrans( 'undo-' . $undoMsg ) . '', true, /* interface */true );
 				}
 
-				if ( $text === false ) {
-					$text = $this->getOriginalContent();
+				if ( $content === false ) {
+					$content = $this->getOriginalContent();
 				}
 			}
 		}
 
 		wfProfileOut( __METHOD__ );
-		return $text;
+		return $content;
 	}
 
 	/**
@@ -884,39 +928,69 @@ class EditPage {
 	 */
 	private function getOriginalContent() {
 		if ( $this->section == 'new' ) {
-			return $this->getCurrentText();
+			return $this->getCurrentContent();
 		}
 		$revision = $this->mArticle->getRevisionFetched();
 		if ( $revision === null ) {
-			return '';
+			if ( !$this->content_model ) $this->content_model = $this->getTitle()->getContentModel();
+			$handler = ContentHandler::getForModelID( $this->content_model );
+
+			return $handler->makeEmptyContent();
 		}
-		return $this->mArticle->getContent();
+		$content = $revision->getContent();
+		return $content;
 	}
 
 	/**
-	 * Get the actual text of the page. This is basically similar to
-	 * WikiPage::getRawText() except that when the page doesn't exist an empty
-	 * string is returned instead of false.
+	 * Get the current content of the page. This is basically similar to
+	 * WikiPage::getContent( Revision::RAW ) except that when the page doesn't exist an empty
+	 * content object is returned instead of null.
 	 *
-	 * @since 1.19
+	 * @since 1.WD
 	 * @return string
 	 */
-	private function getCurrentText() {
-		$text = $this->mArticle->getRawText();
-		if ( $text === false ) {
-			return '';
+	private function getCurrentContent() {
+		$rev = $this->mArticle->getRevision();
+		$content = $rev ? $rev->getContent( Revision::RAW ) : null;
+
+		if ( $content  === false || $content === null ) {
+			if ( !$this->content_model ) $this->content_model = $this->getTitle()->getContentModel();
+			$handler = ContentHandler::getForModelID( $this->content_model );
+
+			return $handler->makeEmptyContent();
 		} else {
-			return $text;
+			#FIXME: nasty side-effect!
+			$this->content_model = $rev->getContentModel();
+			$this->content_format = $rev->getContentFormat();
+
+			return $content;
 		}
 	}
 
+
 	/**
 	 * Use this method before edit() to preload some text into the edit box
 	 *
 	 * @param $text string
+	 * @deprecated since 1.WD
 	 */
 	public function setPreloadedText( $text ) {
-		$this->mPreloadText = $text;
+		wfDeprecated( __METHOD__, "1.WD" );
+
+		$content = ContentHandler::makeContent( $text, $this->getTitle() );
+
+		$this->setPreloadedContent( $content );
+	}
+
+	/**
+	 * Use this method before edit() to preload some content into the edit box
+	 *
+	 * @param $content Content
+	 *
+	 * @since 1.WD
+	 */
+	public function setPreloadedContent( Content $content ) {
+		$this->mPreloadedContent = $content;
 	}
 
 	/**
@@ -924,23 +998,47 @@ class EditPage {
 	 * an earlier setPreloadText() or by loading the given page.
 	 *
 	 * @param $preload String: representing the title to preload from.
+	 *
 	 * @return String
+	 *
+	 * @deprecated since 1.WD, use getPreloadedContent() instead
 	 */
-	protected function getPreloadedText( $preload ) {
-		global $wgUser, $wgParser;
+	protected function getPreloadedText( $preload ) { #NOTE: B/C only, replace usage!
+		wfDeprecated( __METHOD__, "1.WD" );
+
+		$content = $this->getPreloadedContent( $preload );
+		$text = $content->serialize( $this->content_format ); #XXX: really use serialized form? use ContentHandler::getContentText() instead?!
 
-		if ( !empty( $this->mPreloadText ) ) {
-			return $this->mPreloadText;
+		return $text;
+	}
+
+	/**
+	 * Get the contents to be preloaded into the box, either set by
+	 * an earlier setPreloadText() or by loading the given page.
+	 *
+	 * @param $preload String: representing the title to preload from.
+	 *
+	 * @return Content
+	 *
+	 * @since 1.WD
+	 */
+	protected function getPreloadedContent( $preload ) { #@todo: use this!
+		global $wgUser;
+
+		if ( !empty( $this->mPreloadContent ) ) {
+			return $this->mPreloadContent;
 		}
 
+		$handler = ContentHandler::getForTitle( $this->getTitle() );
+
 		if ( $preload === '' ) {
-			return '';
+			return $handler->makeEmptyContent();
 		}
 
 		$title = Title::newFromText( $preload );
 		# Check for existence to avoid getting MediaWiki:Noarticletext
 		if ( $title === null || !$title->exists() || !$title->userCan( 'read' ) ) {
-			return '';
+			return $handler->makeEmptyContent();
 		}
 
 		$page = WikiPage::factory( $title );
@@ -948,13 +1046,15 @@ class EditPage {
 			$title = $page->getRedirectTarget();
 			# Same as before
 			if ( $title === null || !$title->exists() || !$title->userCan( 'read' ) ) {
-				return '';
+				return $handler->makeEmptyContent();
 			}
 			$page = WikiPage::factory( $title );
 		}
 
 		$parserOptions = ParserOptions::newFromUser( $wgUser );
-		return $wgParser->getPreloadText( $page->getRawText(), $title, $parserOptions );
+		$content = $page->getContent( Revision::RAW );
+
+		return $content->preloadTransform( $title, $parserOptions );
 	}
 
 	/**
@@ -1002,6 +1102,11 @@ class EditPage {
 			case self::AS_HOOK_ERROR:
 				return false;
 
+			case self::AS_PARSE_ERROR:
+				$wgOut->addWikiText( '
' . $status->getWikiText() . '
'); + #FIXME: cause editform to be shown again, not just an error! + return false; + case self::AS_SUCCESS_NEW_ARTICLE: $query = $resultDetails['redirect'] ? 'redirect=no' : ''; $anchor = isset ( $resultDetails['sectionanchor'] ) ? $resultDetails['sectionanchor'] : ''; @@ -1097,7 +1202,7 @@ class EditPage { # Check image redirect if ( $this->mTitle->getNamespace() == NS_FILE && - Title::newFromRedirect( $this->textbox1 ) instanceof Title && + Title::newFromRedirect( $this->textbox1 ) instanceof Title && #FIXME: use content handler to check for redirect !$wgUser->isAllowed( 'upload' ) ) { $code = $wgUser->isAnon() ? self::AS_IMAGE_REDIRECT_ANON : self::AS_IMAGE_REDIRECT_LOGGED; $status->setResult( false, $code ); @@ -1207,262 +1312,281 @@ class EditPage { $this->mArticle->loadPageData( 'forupdate' ); $new = !$this->mArticle->exists(); - if ( $new ) { - // Late check for create permission, just in case *PARANOIA* - if ( !$this->mTitle->userCan( 'create' ) ) { - $status->fatal( 'nocreatetext' ); - $status->value = self::AS_NO_CREATE_PERMISSION; - wfDebug( __METHOD__ . ": no create permission\n" ); - wfProfileOut( __METHOD__ ); - return $status; - } + try { + if ( $new ) { + // Late check for create permission, just in case *PARANOIA* + if ( !$this->mTitle->userCan( 'create' ) ) { + $status->fatal( 'nocreatetext' ); + $status->value = self::AS_NO_CREATE_PERMISSION; + wfDebug( __METHOD__ . ": no create permission\n" ); + wfProfileOut( __METHOD__ ); + return $status; + } - # Don't save a new article if it's blank. - if ( $this->textbox1 == '' ) { - $status->setResult( false, self::AS_BLANK_ARTICLE ); - wfProfileOut( __METHOD__ ); - return $status; - } + # Don't save a new article if it's blank. + if ( $this->textbox1 == '' ) { + $status->setResult( false, self::AS_BLANK_ARTICLE ); + wfProfileOut( __METHOD__ ); + return $status; + } - // Run post-section-merge edit filter - if ( !wfRunHooks( 'EditFilterMerged', array( $this, $this->textbox1, &$this->hookError, $this->summary ) ) ) { - # Error messages etc. could be handled within the hook... - $status->fatal( 'hookaborted' ); - $status->value = self::AS_HOOK_ERROR; - wfProfileOut( __METHOD__ ); - return $status; - } elseif ( $this->hookError != '' ) { - # ...or the hook could be expecting us to produce an error - $status->fatal( 'hookaborted' ); - $status->value = self::AS_HOOK_ERROR_EXPECTED; - wfProfileOut( __METHOD__ ); - return $status; - } + // Run post-section-merge edit filter + if ( !wfRunHooks( 'EditFilterMerged', array( $this, $this->textbox1, &$this->hookError, $this->summary ) ) ) { + # Error messages etc. could be handled within the hook... + $status->fatal( 'hookaborted' ); + $status->value = self::AS_HOOK_ERROR; + wfProfileOut( __METHOD__ ); + return $status; + } elseif ( $this->hookError != '' ) { + # ...or the hook could be expecting us to produce an error + $status->fatal( 'hookaborted' ); + $status->value = self::AS_HOOK_ERROR_EXPECTED; + wfProfileOut( __METHOD__ ); + return $status; + } - $text = $this->textbox1; - $result['sectionanchor'] = ''; - if ( $this->section == 'new' ) { - if ( $this->sectiontitle !== '' ) { - // Insert the section title above the content. - $text = wfMsgForContent( 'newsectionheaderdefaultlevel', $this->sectiontitle ) . "\n\n" . $text; - - // Jump to the new section - $result['sectionanchor'] = $wgParser->guessLegacySectionNameFromWikiText( $this->sectiontitle ); - - // If no edit summary was specified, create one automatically from the section - // title and have it link to the new section. Otherwise, respect the summary as - // passed. - if ( $this->summary === '' ) { - $cleanSectionTitle = $wgParser->stripSectionName( $this->sectiontitle ); - $this->summary = wfMsgForContent( 'newsectionsummary', $cleanSectionTitle ); - } - } elseif ( $this->summary !== '' ) { - // Insert the section title above the content. - $text = wfMsgForContent( 'newsectionheaderdefaultlevel', $this->summary ) . "\n\n" . $text; + $content = ContentHandler::makeContent( $this->textbox1, $this->getTitle(), $this->content_model, $this->content_format ); - // Jump to the new section - $result['sectionanchor'] = $wgParser->guessLegacySectionNameFromWikiText( $this->summary ); + $result['sectionanchor'] = ''; + if ( $this->section == 'new' ) { + if ( $this->sectiontitle !== '' ) { + // Insert the section title above the content. + $content = $content->addSectionHeader( $this->sectiontitle ); + + // Jump to the new section + $result['sectionanchor'] = $wgParser->guessLegacySectionNameFromWikiText( $this->sectiontitle ); + + // If no edit summary was specified, create one automatically from the section + // title and have it link to the new section. Otherwise, respect the summary as + // passed. + if ( $this->summary === '' ) { + $cleanSectionTitle = $wgParser->stripSectionName( $this->sectiontitle ); + $this->summary = wfMsgForContent( 'newsectionsummary', $cleanSectionTitle ); + } + } elseif ( $this->summary !== '' ) { + // Insert the section title above the content. + $content = $content->addSectionHeader( $this->sectiontitle ); - // Create a link to the new section from the edit summary. - $cleanSummary = $wgParser->stripSectionName( $this->summary ); - $this->summary = wfMsgForContent( 'newsectionsummary', $cleanSummary ); + // Jump to the new section + $result['sectionanchor'] = $wgParser->guessLegacySectionNameFromWikiText( $this->summary ); + + // Create a link to the new section from the edit summary. + $cleanSummary = $wgParser->stripSectionName( $this->summary ); + $this->summary = wfMsgForContent( 'newsectionsummary', $cleanSummary ); + } } - } - $status->value = self::AS_SUCCESS_NEW_ARTICLE; + $status->value = self::AS_SUCCESS_NEW_ARTICLE; - } else { + } else { # not $new - # Article exists. Check for edit conflict. - $timestamp = $this->mArticle->getTimestamp(); - wfDebug( "timestamp: {$timestamp}, edittime: {$this->edittime}\n" ); + # Article exists. Check for edit conflict. - if ( $timestamp != $this->edittime ) { - $this->isConflict = true; - if ( $this->section == 'new' ) { - if ( $this->mArticle->getUserText() == $wgUser->getName() && - $this->mArticle->getComment() == $this->summary ) { - // Probably a duplicate submission of a new comment. - // This can happen when squid resends a request after - // a timeout but the first one actually went through. - wfDebug( __METHOD__ . ": duplicate new section submission; trigger edit conflict!\n" ); - } else { - // New comment; suppress conflict. + $this->mArticle->clear(); # Force reload of dates, etc. + $timestamp = $this->mArticle->getTimestamp(); + + wfDebug( "timestamp: {$timestamp}, edittime: {$this->edittime}\n" ); + + if ( $timestamp != $this->edittime ) { + $this->isConflict = true; + if ( $this->section == 'new' ) { + if ( $this->mArticle->getUserText() == $wgUser->getName() && + $this->mArticle->getComment() == $this->summary ) { + // Probably a duplicate submission of a new comment. + // This can happen when squid resends a request after + // a timeout but the first one actually went through. + wfDebug( __METHOD__ . ": duplicate new section submission; trigger edit conflict!\n" ); + } else { + // New comment; suppress conflict. + $this->isConflict = false; + wfDebug( __METHOD__ . ": conflict suppressed; new section\n" ); + } + } elseif ( $this->section == '' && $this->userWasLastToEdit( $wgUser->getId(), $this->edittime ) ) { + # Suppress edit conflict with self, except for section edits where merging is required. + wfDebug( __METHOD__ . ": Suppressing edit conflict, same user.\n" ); $this->isConflict = false; - wfDebug( __METHOD__ . ": conflict suppressed; new section\n" ); } - } elseif ( $this->section == '' && $this->userWasLastToEdit( $wgUser->getId(), $this->edittime ) ) { - # Suppress edit conflict with self, except for section edits where merging is required. - wfDebug( __METHOD__ . ": Suppressing edit conflict, same user.\n" ); - $this->isConflict = false; } - } - - // If sectiontitle is set, use it, otherwise use the summary as the section title (for - // backwards compatibility with old forms/bots). - if ( $this->sectiontitle !== '' ) { - $sectionTitle = $this->sectiontitle; - } else { - $sectionTitle = $this->summary; - } - if ( $this->isConflict ) { - wfDebug( __METHOD__ . ": conflict! getting section '$this->section' for time '$this->edittime' (article time '{$timestamp}')\n" ); - $text = $this->mArticle->replaceSection( $this->section, $this->textbox1, $sectionTitle, $this->edittime ); - } else { - wfDebug( __METHOD__ . ": getting section '$this->section'\n" ); - $text = $this->mArticle->replaceSection( $this->section, $this->textbox1, $sectionTitle ); - } - if ( is_null( $text ) ) { - wfDebug( __METHOD__ . ": activating conflict; section replace failed.\n" ); - $this->isConflict = true; - $text = $this->textbox1; // do not try to merge here! - } elseif ( $this->isConflict ) { - # Attempt merge - if ( $this->mergeChangesInto( $text ) ) { - // Successful merge! Maybe we should tell the user the good news? - $this->isConflict = false; - wfDebug( __METHOD__ . ": Suppressing edit conflict, successful merge.\n" ); + // If sectiontitle is set, use it, otherwise use the summary as the section title (for + // backwards compatibility with old forms/bots). + if ( $this->sectiontitle !== '' ) { + $sectionTitle = $this->sectiontitle; } else { - $this->section = ''; - $this->textbox1 = $text; - wfDebug( __METHOD__ . ": Keeping edit conflict, failed merge.\n" ); + $sectionTitle = $this->summary; } - } - if ( $this->isConflict ) { - $status->setResult( false, self::AS_CONFLICT_DETECTED ); - wfProfileOut( __METHOD__ ); - return $status; - } + $textbox_content = ContentHandler::makeContent( $this->textbox1, $this->getTitle(), $this->content_model, $this->content_format ); + $content = null; - // Run post-section-merge edit filter - if ( !wfRunHooks( 'EditFilterMerged', array( $this, $text, &$this->hookError, $this->summary ) ) ) { - # Error messages etc. could be handled within the hook... - $status->fatal( 'hookaborted' ); - $status->value = self::AS_HOOK_ERROR; - wfProfileOut( __METHOD__ ); - return $status; - } elseif ( $this->hookError != '' ) { - # ...or the hook could be expecting us to produce an error - $status->fatal( 'hookaborted' ); - $status->value = self::AS_HOOK_ERROR_EXPECTED; - wfProfileOut( __METHOD__ ); - return $status; - } + if ( $this->isConflict ) { + wfDebug( __METHOD__ . ": conflict! getting section '$this->section' for time '$this->edittime' (article time '{$timestamp}')\n" ); + $content = $this->mArticle->replaceSectionContent( $this->section, $textbox_content, $sectionTitle, $this->edittime ); + } else { + wfDebug( __METHOD__ . ": getting section '$this->section'\n" ); + $content = $this->mArticle->replaceSectionContent( $this->section, $textbox_content, $sectionTitle ); + } - # Handle the user preference to force summaries here, but not for null edits - if ( $this->section != 'new' && !$this->allowBlankSummary - && $this->getOriginalContent() != $text - && !Title::newFromRedirect( $text ) ) # check if it's not a redirect - { - if ( md5( $this->summary ) == $this->autoSumm ) { - $this->missingSummary = true; - $status->fatal( 'missingsummary' ); - $status->value = self::AS_SUMMARY_NEEDED; - wfProfileOut( __METHOD__ ); - return $status; + if ( is_null( $content ) ) { + wfDebug( __METHOD__ . ": activating conflict; section replace failed.\n" ); + $this->isConflict = true; + $content = $textbox_content; // do not try to merge here! + } elseif ( $this->isConflict ) { + # Attempt merge + if ( $this->mergeChangesIntoContent( $textbox_content ) ) { + // Successful merge! Maybe we should tell the user the good news? + $this->isConflict = false; + $content = $textbox_content; + wfDebug( __METHOD__ . ": Suppressing edit conflict, successful merge.\n" ); + } else { + $this->section = ''; + #$this->textbox1 = $text; #redundant, nothing to do here? + wfDebug( __METHOD__ . ": Keeping edit conflict, failed merge.\n" ); + } } - } - # And a similar thing for new sections - if ( $this->section == 'new' && !$this->allowBlankSummary ) { - if ( trim( $this->summary ) == '' ) { - $this->missingSummary = true; - $status->fatal( 'missingsummary' ); // or 'missingcommentheader' if $section == 'new'. Blegh - $status->value = self::AS_SUMMARY_NEEDED; + if ( $this->isConflict ) { + $status->setResult( false, self::AS_CONFLICT_DETECTED ); wfProfileOut( __METHOD__ ); return $status; } - } - # All's well - wfProfileIn( __METHOD__ . '-sectionanchor' ); - $sectionanchor = ''; - if ( $this->section == 'new' ) { - if ( $this->textbox1 == '' ) { - $this->missingComment = true; - $status->fatal( 'missingcommenttext' ); - $status->value = self::AS_TEXTBOX_EMPTY; - wfProfileOut( __METHOD__ . '-sectionanchor' ); + // Run post-section-merge edit filter + if ( !wfRunHooks( 'EditFilterMerged', array( $this, $content->serialize( $this->content_format ), &$this->hookError, $this->summary ) ) + || !wfRunHooks( 'EditFilterMergedContent', array( $this, $content, &$this->hookError, $this->summary ) ) ) { + # Error messages etc. could be handled within the hook... + $status->fatal( 'hookaborted' ); + $status->value = self::AS_HOOK_ERROR; + wfProfileOut( __METHOD__ ); + return $status; + } elseif ( $this->hookError != '' ) { + # ...or the hook could be expecting us to produce an error + $status->fatal( 'hookaborted' ); + $status->value = self::AS_HOOK_ERROR_EXPECTED; wfProfileOut( __METHOD__ ); return $status; } - if ( $this->sectiontitle !== '' ) { - $sectionanchor = $wgParser->guessLegacySectionNameFromWikiText( $this->sectiontitle ); - // If no edit summary was specified, create one automatically from the section - // title and have it link to the new section. Otherwise, respect the summary as - // passed. - if ( $this->summary === '' ) { - $cleanSectionTitle = $wgParser->stripSectionName( $this->sectiontitle ); - $this->summary = wfMsgForContent( 'newsectionsummary', $cleanSectionTitle ); + + $content = ContentHandler::makeContent( $this->textbox1, $this->getTitle(), $this->content_model, $this->content_format ); + + # Handle the user preference to force summaries here, but not for null edits + if ( $this->section != 'new' && !$this->allowBlankSummary + && !$content->equals( $this->getOriginalContent() ) + && !$content->isRedirect() ) # check if it's not a redirect + { + if ( md5( $this->summary ) == $this->autoSumm ) { + $this->missingSummary = true; + $status->fatal( 'missingsummary' ); + $status->value = self::AS_SUMMARY_NEEDED; + wfProfileOut( __METHOD__ ); + return $status; } - } elseif ( $this->summary !== '' ) { - $sectionanchor = $wgParser->guessLegacySectionNameFromWikiText( $this->summary ); - # This is a new section, so create a link to the new section - # in the revision summary. - $cleanSummary = $wgParser->stripSectionName( $this->summary ); - $this->summary = wfMsgForContent( 'newsectionsummary', $cleanSummary ); } - } elseif ( $this->section != '' ) { - # Try to get a section anchor from the section source, redirect to edited section if header found - # XXX: might be better to integrate this into Article::replaceSection - # for duplicate heading checking and maybe parsing - $hasmatch = preg_match( "/^ *([=]{1,6})(.*?)(\\1) *\\n/i", $this->textbox1, $matches ); - # we can't deal with anchors, includes, html etc in the header for now, - # headline would need to be parsed to improve this - if ( $hasmatch && strlen( $matches[2] ) > 0 ) { - $sectionanchor = $wgParser->guessLegacySectionNameFromWikiText( $matches[2] ); + + # And a similar thing for new sections + if ( $this->section == 'new' && !$this->allowBlankSummary ) { + if ( trim( $this->summary ) == '' ) { + $this->missingSummary = true; + $status->fatal( 'missingsummary' ); // or 'missingcommentheader' if $section == 'new'. Blegh + $status->value = self::AS_SUMMARY_NEEDED; + wfProfileOut( __METHOD__ ); + return $status; + } } - } - $result['sectionanchor'] = $sectionanchor; - wfProfileOut( __METHOD__ . '-sectionanchor' ); - // Save errors may fall down to the edit form, but we've now - // merged the section into full text. Clear the section field - // so that later submission of conflict forms won't try to - // replace that into a duplicated mess. - $this->textbox1 = $text; - $this->section = ''; + # All's well + wfProfileIn( __METHOD__ . '-sectionanchor' ); + $sectionanchor = ''; + if ( $this->section == 'new' ) { + if ( $this->textbox1 == '' ) { + $this->missingComment = true; + $status->fatal( 'missingcommenttext' ); + $status->value = self::AS_TEXTBOX_EMPTY; + wfProfileOut( __METHOD__ . '-sectionanchor' ); + wfProfileOut( __METHOD__ ); + return $status; + } + if ( $this->sectiontitle !== '' ) { + $sectionanchor = $wgParser->guessLegacySectionNameFromWikiText( $this->sectiontitle ); + // If no edit summary was specified, create one automatically from the section + // title and have it link to the new section. Otherwise, respect the summary as + // passed. + if ( $this->summary === '' ) { + $cleanSectionTitle = $wgParser->stripSectionName( $this->sectiontitle ); + $this->summary = wfMsgForContent( 'newsectionsummary', $cleanSectionTitle ); + } + } elseif ( $this->summary !== '' ) { + $sectionanchor = $wgParser->guessLegacySectionNameFromWikiText( $this->summary ); + # This is a new section, so create a link to the new section + # in the revision summary. + $cleanSummary = $wgParser->stripSectionName( $this->summary ); + $this->summary = wfMsgForContent( 'newsectionsummary', $cleanSummary ); + } + } elseif ( $this->section != '' ) { + # Try to get a section anchor from the section source, redirect to edited section if header found + # XXX: might be better to integrate this into Article::replaceSection + # for duplicate heading checking and maybe parsing + $hasmatch = preg_match( "/^ *([=]{1,6})(.*?)(\\1) *\\n/i", $this->textbox1, $matches ); + # we can't deal with anchors, includes, html etc in the header for now, + # headline would need to be parsed to improve this + if ( $hasmatch && strlen( $matches[2] ) > 0 ) { + $sectionanchor = $wgParser->guessLegacySectionNameFromWikiText( $matches[2] ); + } + } + $result['sectionanchor'] = $sectionanchor; + wfProfileOut( __METHOD__ . '-sectionanchor' ); - $status->value = self::AS_SUCCESS_UPDATE; - } + // Save errors may fall down to the edit form, but we've now + // merged the section into full text. Clear the section field + // so that later submission of conflict forms won't try to + // replace that into a duplicated mess. + $this->textbox1 = $content->serialize( $this->content_format ); + $this->section = ''; - // Check for length errors again now that the section is merged in - $this->kblength = (int)( strlen( $text ) / 1024 ); - if ( $this->kblength > $wgMaxArticleSize ) { - $this->tooBig = true; - $status->setResult( false, self::AS_MAX_ARTICLE_SIZE_EXCEEDED ); - wfProfileOut( __METHOD__ ); - return $status; - } + $status->value = self::AS_SUCCESS_UPDATE; + } + + // Check for length errors again now that the section is merged in + $this->kblength = (int)( strlen( $content->serialize( $this->content_format ) ) / 1024 ); + if ( $this->kblength > $wgMaxArticleSize ) { + $this->tooBig = true; + $status->setResult( false, self::AS_MAX_ARTICLE_SIZE_EXCEEDED ); + wfProfileOut( __METHOD__ ); + return $status; + } - $flags = EDIT_DEFER_UPDATES | EDIT_AUTOSUMMARY | - ( $new ? EDIT_NEW : EDIT_UPDATE ) | - ( ( $this->minoredit && !$this->isNew ) ? EDIT_MINOR : 0 ) | - ( $bot ? EDIT_FORCE_BOT : 0 ); + $flags = EDIT_DEFER_UPDATES | EDIT_AUTOSUMMARY | + ( $new ? EDIT_NEW : EDIT_UPDATE ) | + ( ( $this->minoredit && !$this->isNew ) ? EDIT_MINOR : 0 ) | + ( $bot ? EDIT_FORCE_BOT : 0 ); - $doEditStatus = $this->mArticle->doEdit( $text, $this->summary, $flags ); + $doEditStatus = $this->mArticle->doEditContent( $content, $this->summary, $flags, false, null, $this->content_format ); - if ( $doEditStatus->isOK() ) { - $result['redirect'] = Title::newFromRedirect( $text ) !== null; - $this->commitWatch(); - wfProfileOut( __METHOD__ ); - return $status; - } else { - // Failure from doEdit() - // Show the edit conflict page for certain recognized errors from doEdit(), - // but don't show it for errors from extension hooks - $errors = $doEditStatus->getErrorsArray(); - if ( in_array( $errors[0][0], array( 'edit-gone-missing', 'edit-conflict', - 'edit-already-exists' ) ) ) - { - $this->isConflict = true; - // Destroys data doEdit() put in $status->value but who cares - $doEditStatus->value = self::AS_END; + if ( $doEditStatus->isOK() ) { + $result['redirect'] = $content->isRedirect(); + $this->commitWatch(); + wfProfileOut( __METHOD__ ); + return $status; + } else { + // Failure from doEdit() + // Show the edit conflict page for certain recognized errors from doEdit(), + // but don't show it for errors from extension hooks + $errors = $doEditStatus->getErrorsArray(); + if ( in_array( $errors[0][0], array( 'edit-gone-missing', 'edit-conflict', + 'edit-already-exists' ) ) ) + { + $this->isConflict = true; + // Destroys data doEdit() put in $status->value but who cares + $doEditStatus->value = self::AS_END; + } + wfProfileOut( __METHOD__ ); + return $doEditStatus; } + } catch (MWContentSerializationException $ex) { + $status->fatal( 'content-failed-to-parse', $this->content_model, $this->content_format, $ex->getMessage() ); + $status->value = self::AS_PARSE_ERROR; wfProfileOut( __METHOD__ ); - return $doEditStatus; + return $status; } } @@ -1519,8 +1643,33 @@ class EditPage { * @parma $editText string * * @return bool + * @deprecated since 1.WD, use mergeChangesIntoContent() instead */ - function mergeChangesInto( &$editText ) { + function mergeChangesInto( &$editText ){ + wfDebug( __METHOD__, "1.WD" ); + + $editContent = ContentHandler::makeContent( $editText, $this->getTitle(), $this->content_model, $this->content_format ); + + $ok = $this->mergeChangesIntoContent( $editContent ); + + if ( $ok ) { + $editText = $editContent->serialize( $this->content_format ); #XXX: really serialize?! + return true; + } else { + return false; + } + } + + /** + * @private + * @todo document + * + * @parma $editText string + * + * @return bool + * @since since 1.WD + */ + private function mergeChangesIntoContent( &$editContent ){ wfProfileIn( __METHOD__ ); $db = wfGetDB( DB_MASTER ); @@ -1531,7 +1680,7 @@ class EditPage { wfProfileOut( __METHOD__ ); return false; } - $baseText = $baseRevision->getText(); + $baseContent = $baseRevision->getContent(); // The current state, we want to merge updates into it $currentRevision = Revision::loadFromTitle( $db, $this->mTitle ); @@ -1539,11 +1688,14 @@ class EditPage { wfProfileOut( __METHOD__ ); return false; } - $currentText = $currentRevision->getText(); + $currentContent = $currentRevision->getContent(); + + $handler = ContentHandler::getForModelID( $baseContent->getModel() ); - $result = ''; - if ( wfMerge( $baseText, $editText, $currentText, $result ) ) { - $editText = $result; + $result = $handler->merge3( $baseContent, $editContent, $currentContent ); + + if ( $result ) { + $editContent = $result; wfProfileOut( __METHOD__ ); return true; } else { @@ -1790,6 +1942,7 @@ class EditPage { } } + #FIXME: add EditForm plugin interface and use it here! #FIXME: search for textarea1 and textares2, and allow EditForm to override all uses. $wgOut->addHTML( Html::openElement( 'form', array( 'id' => self::EDITFORM_ID, 'name' => self::EDITFORM_ID, 'method' => 'post', 'action' => $this->getActionURL( $this->getContextTitle() ), 'enctype' => 'multipart/form-data' ) ) ); @@ -1850,6 +2003,9 @@ class EditPage { $wgOut->addHTML( Html::hidden( 'oldid', $this->oldid ) ); + $wgOut->addHTML( Html::hidden( 'format', $this->content_format ) ); + $wgOut->addHTML( Html::hidden( 'model', $this->content_model ) ); + if ( $this->section == 'new' ) { $this->showSummaryInput( true, $this->summary ); $wgOut->addHTML( $this->getSummaryPreview( true, $this->summary ) ); @@ -1867,7 +2023,9 @@ class EditPage { // resolved between page source edits and custom ui edits using the // custom edit ui. $this->textbox2 = $this->textbox1; - $this->textbox1 = $this->getCurrentText(); + + $content = $this->getCurrentContent(); + $this->textbox1 = $content->serialize( $this->content_format ); $this->showTextbox1(); } else { @@ -2275,10 +2433,10 @@ HTML $this->showTextbox( $this->textbox2, 'wpTextbox2', array( 'tabindex' => 6, 'readonly' ) ); } - protected function showTextbox( $content, $name, $customAttribs = array() ) { + protected function showTextbox( $text, $name, $customAttribs = array() ) { global $wgOut, $wgUser; - $wikitext = $this->safeUnicodeOutput( $content ); + $wikitext = $this->safeUnicodeOutput( $text ); if ( strval( $wikitext ) !== '' ) { // Ensure there's a newline at the end, otherwise adding lines // is awkward. @@ -2362,24 +2520,43 @@ HTML $oldtext = $this->mTitle->getDefaultMessageText(); if( $oldtext !== false ) { $oldtitlemsg = 'defaultmessagetext'; + $oldContent = ContentHandler::makeContent( $oldtext, $this->mTitle ); + } else { + $oldContent = null; } } else { - $oldtext = $this->mArticle->getRawText(); + $oldContent = $this->getOriginalContent(); } - $newtext = $this->mArticle->replaceSection( - $this->section, $this->textbox1, $this->summary, $this->edittime ); + $textboxContent = ContentHandler::makeContent( $this->textbox1, $this->getTitle(), + $this->content_model, $this->content_format ); #XXX: handle parse errors ? + + $newContent = $this->mArticle->replaceSectionContent( + $this->section, $textboxContent, + $this->summary, $this->edittime ); + + # hanlde legacy text-based hook + $newtext_orig = $newContent->serialize( $this->content_format ); + $newtext = $newtext_orig; #clone wfRunHooks( 'EditPageGetDiffText', array( $this, &$newtext ) ); + if ( $newtext != $newtext_orig ) { + #if the hook changed the text, create a new Content object accordingly. + $newContent = ContentHandler::makeContent( $newtext, $this->getTitle(), $newContent->getModel() ); #XXX: handle parse errors ? + } + + wfRunHooks( 'EditPageGetDiffContent', array( $this, &$newContent ) ); + $popts = ParserOptions::newFromUserAndLang( $wgUser, $wgContLang ); - $newtext = $wgParser->preSaveTransform( $newtext, $this->mTitle, $wgUser, $popts ); + $newContent = $newContent->preSaveTransform( $this->mTitle, $wgUser, $popts ); - if ( $oldtext !== false || $newtext != '' ) { + if ( ( $oldContent && !$oldContent->isEmpty() ) || ( $newContent && !$newContent->isEmpty() ) ) { $oldtitle = wfMsgExt( $oldtitlemsg, array( 'parseinline' ) ); $newtitle = wfMsgExt( 'yourtext', array( 'parseinline' ) ); - $de = new DifferenceEngine( $this->mArticle->getContext() ); - $de->setText( $oldtext, $newtext ); + $de = $oldContent->getContentHandler()->createDifferenceEngine( $this->mArticle->getContext() ); + $de->setContent( $oldContent, $newContent ); + $difftext = $de->getDiff( $oldtitle, $newtitle ); $de->showDiffStyle(); } else { @@ -2469,8 +2646,12 @@ HTML if ( wfRunHooks( 'EditPageBeforeConflictDiff', array( &$this, &$wgOut ) ) ) { $wgOut->wrapWikiMsg( '

$1

', "yourdiff" ); - $de = new DifferenceEngine( $this->mArticle->getContext() ); - $de->setText( $this->textbox2, $this->textbox1 ); + $content1 = ContentHandler::makeContent( $this->textbox1, $this->getTitle(), $this->content_model, $this->content_format ); #XXX: handle parse errors? + $content2 = ContentHandler::makeContent( $this->textbox2, $this->getTitle(), $this->content_model, $this->content_format ); #XXX: handle parse errors? + + $handler = ContentHandler::getForModelID( $this->content_model ); + $de = $handler->createDifferenceEngine( $this->mArticle->getContext() ); + $de->setContent( $content2, $content1 ); $de->showDiff( wfMsgExt( 'yourtext', 'parseinline' ), wfMsg( 'storedversion' ) ); $wgOut->wrapWikiMsg( '

$1

', "yourtext" ); @@ -2590,84 +2771,101 @@ HTML return $parsedNote; } - if ( $this->mTriedSave && !$this->mTokenOk ) { - if ( $this->mTokenOkExceptSuffix ) { - $note = wfMsg( 'token_suffix_mismatch' ); - } else { - $note = wfMsg( 'session_fail_preview' ); - } - } elseif ( $this->incompleteForm ) { - $note = wfMsg( 'edit_form_incomplete' ); - } else { - $note = wfMsg( 'previewnote' ) . - ' [[#' . self::EDITFORM_ID . '|' . $wgLang->getArrow() . ' ' . wfMsg( 'continue-editing' ) . ']]'; - } + $note = ''; - $parserOptions = ParserOptions::newFromUser( $wgUser ); - $parserOptions->setEditSection( false ); - $parserOptions->setTidy( true ); - $parserOptions->setIsPreview( true ); - $parserOptions->setIsSectionPreview( !is_null( $this->section ) && $this->section !== '' ); - - # don't parse non-wikitext pages, show message about preview - if ( $this->mTitle->isCssJsSubpage() || !$this->mTitle->isWikitextPage() ) { - if ( $this->mTitle->isCssJsSubpage() ) { - $level = 'user'; - } elseif ( $this->mTitle->isCssOrJsPage() ) { - $level = 'site'; - } else { - $level = false; - } + try { + $content = ContentHandler::makeContent( $this->textbox1, $this->getTitle(), $this->content_model, $this->content_format ); - # Used messages to make sure grep find them: - # Messages: usercsspreview, userjspreview, sitecsspreview, sitejspreview - $class = 'mw-code'; - if ( $level ) { - if ( preg_match( "/\\.css$/", $this->mTitle->getText() ) ) { - $previewtext = "
\n" . wfMsg( "{$level}csspreview" ) . "\n
"; - $class .= " mw-css"; - } elseif ( preg_match( "/\\.js$/", $this->mTitle->getText() ) ) { - $previewtext = "
\n" . wfMsg( "{$level}jspreview" ) . "\n
"; - $class .= " mw-js"; + if ( $this->mTriedSave && !$this->mTokenOk ) { + if ( $this->mTokenOkExceptSuffix ) { + $note = wfMsg( 'token_suffix_mismatch' ); } else { - throw new MWException( 'A CSS/JS (sub)page but which is not css nor js!' ); + $note = wfMsg( 'session_fail_preview' ); } - $parserOutput = $wgParser->parse( $previewtext, $this->mTitle, $parserOptions ); - $previewHTML = $parserOutput->getText(); + } elseif ( $this->incompleteForm ) { + $note = wfMsg( 'edit_form_incomplete' ); } else { - $previewHTML = ''; - } + $note = wfMsg( 'previewnote' ) . + ' [[#' . self::EDITFORM_ID . '|' . $wgLang->getArrow() . ' ' . wfMsg( 'continue-editing' ) . ']]'; + } + + $parserOptions = ParserOptions::newFromUser( $wgUser ); + $parserOptions->setEditSection( false ); + $parserOptions->setTidy( true ); + $parserOptions->setIsPreview( true ); + $parserOptions->setIsSectionPreview( !is_null($this->section) && $this->section !== '' ); + + if ( $this->mTitle->isCssJsSubpage() || $this->mTitle->isCssOrJsPage() ) { + # don't parse non-wikitext pages, show message about preview + if( $this->mTitle->isCssJsSubpage() ) { + $level = 'user'; + } elseif( $this->mTitle->isCssOrJsPage() ) { + $level = 'site'; + } else { + $level = false; + } - $previewHTML .= "
\n" . htmlspecialchars( $this->textbox1 ) . "\n
\n"; - } else { - $toparse = $this->textbox1; + if ( $content->getModel() == CONTENT_MODEL_CSS ) { + $format = 'css'; + } elseif ( $content->getModel() == CONTENT_MODEL_JAVASCRIPT ) { + $format = 'js'; + } else { + $format = false; + } - # If we're adding a comment, we need to show the - # summary as the headline - if ( $this->section == "new" && $this->summary != "" ) { - $toparse = wfMsgForContent( 'newsectionheaderdefaultlevel', $this->summary ) . "\n\n" . $toparse; + # Used messages to make sure grep find them: + # Messages: usercsspreview, userjspreview, sitecsspreview, sitejspreview + if( $level && $format ) { + $note = "
" . wfMsg( "{$level}{$format}preview" ) . "
"; + } else { + $note = wfMsg( 'previewnote' ); + } + } else { + $note = wfMsg( 'previewnote' ); } - wfRunHooks( 'EditPageGetPreviewText', array( $this, &$toparse ) ); - - $parserOptions->enableLimitReport(); - - $toparse = $wgParser->preSaveTransform( $toparse, $this->mTitle, $wgUser, $parserOptions ); - $parserOutput = $wgParser->parse( $toparse, $this->mTitle, $parserOptions ); + $rt = $content->getRedirectChain(); - $rt = Title::newFromRedirectArray( $this->textbox1 ); if ( $rt ) { $previewHTML = $this->mArticle->viewRedirect( $rt, false ); } else { - $previewHTML = $parserOutput->getText(); - } - $this->mParserOutput = $parserOutput; - $wgOut->addParserOutputNoText( $parserOutput ); + # If we're adding a comment, we need to show the + # summary as the headline + if ( $this->section == "new" && $this->summary != "" ) { + $content = $content->addSectionHeader( $this->summary ); + } + + $toparse_orig = $content->serialize( $this->content_format ); + $toparse = $toparse_orig; + wfRunHooks( 'EditPageGetPreviewText', array( $this, &$toparse ) ); + + if ( $toparse !== $toparse_orig ) { + #hook changed the text, create new Content object + $content = ContentHandler::makeContent( $toparse, $this->getTitle(), $this->content_model, $this->content_format ); + } + + wfRunHooks( 'EditPageGetPreviewContent', array( $this, &$content ) ); - if ( count( $parserOutput->getWarnings() ) ) { - $note .= "\n\n" . implode( "\n\n", $parserOutput->getWarnings() ); + $parserOptions->enableLimitReport(); + + #XXX: For CSS/JS pages, we should have called the ShowRawCssJs hook here. But it's now deprecated, so never mind + $content = $content->preSaveTransform( $this->mTitle, $wgUser, $parserOptions ); + + // TODO: might be a saner way to get a meaningfull context here? + $parserOutput = $content->getParserOutput( $this->getArticle()->getTitle(), null, $parserOptions ); + + $previewHTML = $parserOutput->getText(); + $this->mParserOutput = $parserOutput; + $wgOut->addParserOutputNoText( $parserOutput ); + + if ( count( $parserOutput->getWarnings() ) ) { + $note .= "\n\n" . implode( "\n\n", $parserOutput->getWarnings() ); + } } + } catch (MWContentSerializationException $ex) { + $note .= "\n\n" . wfMsg('content-failed-to-parse', $this->content_model, $this->content_format, $ex->getMessage() ); + $previewHTML = ''; } if ( $this->isConflict ) { diff --git a/includes/Export.php b/includes/Export.php index 6c47e341fd..4e02ffa4a1 100644 --- a/includes/Export.php +++ b/includes/Export.php @@ -644,12 +644,6 @@ class XmlDumpWriter { $out .= " " . Xml::elementClean( 'comment', array(), strval( $row->rev_comment ) ) . "\n"; } - if ( $row->rev_sha1 && !( $row->rev_deleted & Revision::DELETED_TEXT ) ) { - $out .= " " . Xml::element('sha1', null, strval( $row->rev_sha1 ) ) . "\n"; - } else { - $out .= " \n"; - } - $text = ''; if ( $row->rev_deleted & Revision::DELETED_TEXT ) { $out .= " " . Xml::element( 'text', array( 'deleted' => 'deleted' ) ) . "\n"; @@ -666,6 +660,36 @@ class XmlDumpWriter { "" ) . "\n"; } + if ( $row->rev_sha1 && !( $row->rev_deleted & Revision::DELETED_TEXT ) ) { + $out .= " " . Xml::element('sha1', null, strval( $row->rev_sha1 ) ) . "\n"; + } else { + $out .= " \n"; + } + + if ( isset( $row->rev_content_model ) && !is_null( $row->rev_content_model ) ) { + $content_model = intval( $row->rev_content_model ); + } else { + // probably using $wgContentHandlerUseDB = false; + // @todo: test! + $title = Title::makeTitle( $row->page_namespace, $row->page_title ); + $content_model = ContentHandler::getDefaultModelFor( $title ); + } + + $name = ContentHandler::getContentModelName( $content_model ); + $out .= " " . Xml::element('model', array( 'name' => $name ), strval( $content_model ) ) . "\n"; + + if ( isset( $row->rev_content_format ) && !is_null( $row->rev_content_format ) ) { + $content_format = intval( $row->rev_content_format ); + } else { + // probably using $wgContentHandlerUseDB = false; + // @todo: test! + $content_handler = ContentHandler::getForModelID( $content_model ); + $content_format = $content_handler->getDefaultFormat(); + } + + $mime = ContentHandler::getContentFormatMimeType( $content_format ); + $out .= " " . Xml::element('format', array( 'mime' => $mime ), strval( $content_format ) ) . "\n"; + wfRunHooks( 'XmlDumpWriterWriteRevision', array( &$this, &$out, $row, $text ) ); $out .= " \n"; diff --git a/includes/FeedUtils.php b/includes/FeedUtils.php index 1c5e777481..a09425532a 100644 --- a/includes/FeedUtils.php +++ b/includes/FeedUtils.php @@ -138,7 +138,8 @@ class FeedUtils { $diffText = ''; // Don't bother generating the diff if we won't be able to show it if ( $wgFeedDiffCutoff > 0 ) { - $de = new DifferenceEngine( $title, $oldid, $newid ); + $contentHandler = ContentHandler::getForTitle( $title ); + $de = $contentHandler->createDifferenceEngine( $title, $oldid, $newid ); $diffText = $de->getDiff( wfMsg( 'previousrevision' ), // hack wfMsg( 'revisionasof', diff --git a/includes/ImagePage.php b/includes/ImagePage.php index 8f683e9207..6fd253a813 100644 --- a/includes/ImagePage.php +++ b/includes/ImagePage.php @@ -157,7 +157,9 @@ class ImagePage extends Article { $out->addHTML( Xml::openElement( 'div', array( 'id' => 'mw-imagepage-content', 'lang' => $pageLang->getCode(), 'dir' => $pageLang->getDir(), 'class' => 'mw-content-'.$pageLang->getDir() ) ) ); + parent::view(); + $out->addHTML( Xml::closeElement( 'div' ) ); } else { # Just need to set the right headers @@ -268,20 +270,20 @@ class ImagePage extends Article { return $r; } - /** - * Overloading Article's getContent method. - * - * Omit noarticletext if sharedupload; text will be fetched from the - * shared upload server if possible. - * @return string - */ - public function getContent() { - $this->loadFile(); - if ( $this->mPage->getFile() && !$this->mPage->getFile()->isLocal() && 0 == $this->getID() ) { - return ''; - } - return parent::getContent(); - } + /** + * Overloading Article's getContentObject method. + * + * Omit noarticletext if sharedupload; text will be fetched from the + * shared upload server if possible. + * @return string + */ + public function getContentObject() { + $this->loadFile(); + if ( $this->mPage->getFile() && !$this->mPage->getFile()->isLocal() && 0 == $this->getID() ) { + return null; + } + return parent::getContentObject(); + } protected function openShowImage() { global $wgImageLimits, $wgEnableUploads, $wgSend404Code; diff --git a/includes/Import.php b/includes/Import.php index 9ebc34c91b..5b182add6a 100644 --- a/includes/Import.php +++ b/includes/Import.php @@ -578,7 +578,7 @@ class WikiImporter { $this->debug( "Enter revision handler" ); $revisionInfo = array(); - $normalFields = array( 'id', 'timestamp', 'comment', 'minor', 'text' ); + $normalFields = array( 'id', 'timestamp', 'comment', 'minor', 'model', 'format', 'text' ); $skip = false; @@ -623,6 +623,12 @@ class WikiImporter { if ( isset( $revisionInfo['text'] ) ) { $revision->setText( $revisionInfo['text'] ); } + if ( isset( $revisionInfo['model'] ) ) { + $revision->setModel( $revisionInfo['model'] ); + } + if ( isset( $revisionInfo['text'] ) ) { + $revision->setFormat( $revisionInfo['format'] ); + } $revision->setTitle( $pageInfo['_title'] ); if ( isset( $revisionInfo['timestamp'] ) ) { @@ -972,6 +978,8 @@ class WikiRevision { var $timestamp = "20010115000000"; var $user = 0; var $user_text = ""; + var $model = null; + var $format = null; var $text = ""; var $comment = ""; var $minor = false; @@ -1028,6 +1036,20 @@ class WikiRevision { $this->user_text = $ip; } + /** + * @param $model + */ + function setModel( $model ) { + $this->model = $model; + } + + /** + * @param $format + */ + function setFormat( $format ) { + $this->format = $format; + } + /** * @param $text */ @@ -1156,6 +1178,28 @@ class WikiRevision { return $this->text; } + /** + * @return int + */ + function getModel() { + if ( is_null( $this->model ) ) { + $this->model = $this->getTitle()->getContentModel(); + } + + return $this->model; + } + + /** + * @return int + */ + function getFormat() { + if ( is_null( $this->model ) ) { + $this->format = ContentHandler::getForTitle( $this->getTitle() )->getDefaultFormat(); + } + + return $this->format; + } + /** * @return string */ @@ -1295,6 +1339,8 @@ class WikiRevision { # Insert the row $revision = new Revision( array( 'page' => $pageId, + 'content_model' => $this->getModel(), + 'content_format' => $this->getFormat(), 'text' => $this->getText(), 'comment' => $this->getComment(), 'user' => $userId, diff --git a/includes/LinksUpdate.php b/includes/LinksUpdate.php index 0712ac807b..3e8e362c7a 100644 --- a/includes/LinksUpdate.php +++ b/includes/LinksUpdate.php @@ -39,8 +39,6 @@ class LinksUpdate extends SqlDataUpdate { $mCategories, //!< Map of category names to sort keys $mInterlangs, //!< Map of language codes to titles $mProperties, //!< Map of arbitrary name to value - $mDb, //!< Database connection reference - $mOptions, //!< SELECT options to be used (array) $mRecursive; //!< Whether to queue jobs for recursive updates /** @@ -71,6 +69,7 @@ class LinksUpdate extends SqlDataUpdate { } $this->mParserOutput = $parserOutput; + $this->mLinks = $parserOutput->getLinks(); $this->mImages = $parserOutput->getImages(); $this->mTemplates = $parserOutput->getTemplates(); @@ -817,7 +816,7 @@ class LinksUpdate extends SqlDataUpdate { **/ class LinksDeletionUpdate extends SqlDataUpdate { - protected $mPage; //!< WikiPage the wikipage that was deleted + protected $mTitle; //!< Title the title of page that was deleted /** * Constructor @@ -826,18 +825,22 @@ class LinksDeletionUpdate extends SqlDataUpdate { * @param $parserOutput ParserOutput: output from a full parse of this page * @param $recursive Boolean: queue jobs for recursive updates? */ - function __construct( WikiPage $page ) { + function __construct( Title $title ) { parent::__construct( ); - $this->mPage = $page; + $this->mTitle = $title; + + if ( !$title->getArticleID() ) { + throw new MWException( "The Title object did not provide an article ID. Perhaps the page doesn't exist?" ); + } } /** * Do some database updates after deletion */ public function doUpdate() { - $title = $this->mPage->getTitle(); - $id = $this->mPage->getId(); + $title = $this->mTitle; + $id = $title->getArticleID(); # Delete restrictions for it $this->mDb->delete( 'page_restrictions', array ( 'pr_page' => $id ), __METHOD__ ); @@ -850,7 +853,7 @@ class LinksDeletionUpdate extends SqlDataUpdate { $cats [] = $row->cl_to; } - $this->mPage->updateCategoryCounts( array(), $cats ); + $this->updateCategoryCounts( array(), $cats ); # If using cascading deletes, we can skip some explicit deletes if ( !$this->mDb->cascadingDeletes() ) { @@ -881,4 +884,16 @@ class LinksDeletionUpdate extends SqlDataUpdate { __METHOD__ ); } } + + /** + * Update all the appropriate counts in the category table. + * @param $added array associative array of category name => sort key + * @param $deleted array associative array of category name => sort key + */ + function updateCategoryCounts( $added, $deleted ) { + $a = WikiPage::factory( $this->mTitle ); + $a->updateCategoryCounts( + array_keys( $added ), array_keys( $deleted ) + ); + } } diff --git a/includes/Message.php b/includes/Message.php index 3a87a001fc..86d36096b4 100644 --- a/includes/Message.php +++ b/includes/Message.php @@ -202,6 +202,11 @@ class Message { */ protected $title = null; + /** + * Content object representing the message + */ + protected $content = null; + /** * @var string */ @@ -395,6 +400,18 @@ class Message { return $this; } + /** + * Returns the message as a Content object. + * @return Content + */ + public function content() { + if ( !$this->content ) { + $this->content = new MessageContent( $this->key ); + } + + return $this->content; + } + /** * Returns the message parsed from wikitext to HTML. * @return String: HTML diff --git a/includes/Namespace.php b/includes/Namespace.php index c87a12b7c0..9662fc895e 100644 --- a/includes/Namespace.php +++ b/includes/Namespace.php @@ -209,12 +209,14 @@ class MWNamespace { * Returns array of all defined namespaces with their canonical * (English) names. * + * @param bool $rebuild rebuild namespace list (default = false). Used for testing. + * * @return array * @since 1.17 */ - public static function getCanonicalNamespaces() { + public static function getCanonicalNamespaces( $rebuild = false ) { static $namespaces = null; - if ( $namespaces === null ) { + if ( $namespaces === null || $rebuild ) { global $wgExtraNamespaces, $wgCanonicalNamespaceNames; $namespaces = array( NS_MAIN => '' ) + $wgCanonicalNamespaceNames; if ( is_array( $wgExtraNamespaces ) ) { diff --git a/includes/Revision.php b/includes/Revision.php index 6b8aabc640..6861605a26 100644 --- a/includes/Revision.php +++ b/includes/Revision.php @@ -40,6 +40,10 @@ class Revision { protected $mTextRow; protected $mTitle; protected $mCurrent; + protected $mContentModel; + protected $mContentFormat; + protected $mContent; + protected $mContentHandler; const DELETED_TEXT = 1; const DELETED_COMMENT = 2; @@ -133,6 +137,8 @@ class Revision { * @return Revision */ public static function newFromArchiveRow( $row, $overrides = array() ) { + global $wgContentHandlerUseDB; + $attribs = $overrides + array( 'page' => isset( $row->ar_page_id ) ? $row->ar_page_id : null, 'id' => isset( $row->ar_rev_id ) ? $row->ar_rev_id : null, @@ -145,7 +151,15 @@ class Revision { 'deleted' => $row->ar_deleted, 'len' => $row->ar_len, 'sha1' => isset( $row->ar_sha1 ) ? $row->ar_sha1 : null, + 'content_model' => isset( $row->ar_content_model ) ? $row->ar_content_model : null, + 'content_format' => isset( $row->ar_content_format ) ? $row->ar_content_format : null, ); + + if ( !$wgContentHandlerUseDB ) { + unset( $attribs['content_model'] ); + unset( $attribs['content_format'] ); + } + if ( isset( $row->ar_text ) && !$row->ar_text_id ) { // Pre-1.5 ar_text row $attribs['text'] = self::getRevisionText( $row, 'ar_' ); @@ -344,7 +358,9 @@ class Revision { * @return array */ public static function selectFields() { - return array( + global $wgContentHandlerUseDB; + + $fields = array( 'rev_id', 'rev_page', 'rev_text_id', @@ -356,8 +372,15 @@ class Revision { 'rev_deleted', 'rev_len', 'rev_parent_id', - 'rev_sha1' + 'rev_sha1', ); + + if ( $wgContentHandlerUseDB ) { + $fields[] = 'rev_content_format'; + $fields[] = 'rev_content_model'; + } + + return $fields; } /** @@ -438,6 +461,18 @@ class Revision { $this->mTitle = null; } + if( !isset( $row->rev_content_model ) || is_null( $row->rev_content_model ) ) { + $this->mContentModel = null; # determine on demand if needed + } else { + $this->mContentModel = intval( $row->rev_content_model ); + } + + if( !isset( $row->rev_content_format ) || is_null( $row->rev_content_format ) ) { + $this->mContentFormat = null; # determine on demand if needed + } else { + $this->mContentFormat = intval( $row->rev_content_format ); + } + // Lazy extraction... $this->mText = null; if( isset( $row->old_text ) ) { @@ -459,6 +494,19 @@ class Revision { // Build a new revision to be saved... global $wgUser; // ugh + + # if we have a content object, use it to set the model and type + if ( !empty( $row['content'] ) ) { + if ( !empty( $row['text_id'] ) ) { //@todo: when is that set? test with external store setup! check out insertOn() [dk] + throw new MWException( "Text already stored in external store (id {$row['text_id']}), can't serialize content object" ); + } + + $row['content_model'] = $row['content']->getModel(); + # note: mContentFormat is initializes later accordingly + # note: content is serialized later in this method! + # also set text to null? + } + $this->mId = isset( $row['id'] ) ? intval( $row['id'] ) : null; $this->mPage = isset( $row['page'] ) ? intval( $row['page'] ) : null; $this->mTextId = isset( $row['text_id'] ) ? intval( $row['text_id'] ) : null; @@ -471,21 +519,48 @@ class Revision { $this->mParentId = isset( $row['parent_id'] ) ? intval( $row['parent_id'] ) : null; $this->mSha1 = isset( $row['sha1'] ) ? strval( $row['sha1'] ) : null; + $this->mContentModel = isset( $row['content_model'] ) ? intval( $row['content_model'] ) : null; + $this->mContentFormat = isset( $row['content_format'] ) ? intval( $row['content_format'] ) : null; + // Enforce spacing trimming on supplied text $this->mComment = isset( $row['comment'] ) ? trim( strval( $row['comment'] ) ) : null; $this->mText = isset( $row['text'] ) ? rtrim( strval( $row['text'] ) ) : null; $this->mTextRow = null; + # if we have a content object, override mText and mContentModel + if ( !empty( $row['content'] ) ) { + $handler = $this->getContentHandler(); + $this->mContent = $row['content']; + + $this->mContentModel = $this->mContent->getModel(); + $this->mContentHandler = null; + + $this->mText = $handler->serializeContent( $row['content'], $this->getContentFormat() ); + } elseif ( !is_null( $this->mText ) ) { + $handler = $this->getContentHandler(); + $this->mContent = $handler->unserializeContent( $this->mText ); + } + $this->mTitle = null; # Load on demand if needed - $this->mCurrent = false; + $this->mCurrent = false; # XXX: really? we are about to create a revision. it will usually then be the current one. + # If we still have no length, see it we have the text to figure it out if ( !$this->mSize ) { - $this->mSize = is_null( $this->mText ) ? null : strlen( $this->mText ); + if ( !is_null( $this->mContent ) ) { + $this->mSize = $this->mContent->getSize(); + } else { + #NOTE: this should never happen if we have either text or content object! + $this->mSize = null; + } } + # Same for sha1 if ( $this->mSha1 === null ) { $this->mSha1 = is_null( $this->mText ) ? null : self::base36Sha1( $this->mText ); } + + $this->getContentModel(); # force lazy init + $this->getContentFormat(); # force lazy init } else { throw new MWException( 'Revision constructor passed invalid row format.' ); } @@ -558,7 +633,7 @@ class Revision { if( isset( $this->mTitle ) ) { return $this->mTitle; } - if( !is_null( $this->mId ) ) { //rev_id is defined as NOT NULL + if( !is_null( $this->mId ) ) { //rev_id is defined as NOT NULL, but this revision may not yet have been inserted. $dbr = wfGetDB( DB_SLAVE ); $row = $dbr->selectRow( array( 'page', 'revision' ), @@ -570,6 +645,8 @@ class Revision { $this->mTitle = Title::newFromRow( $row ); } } + + //@todo: as a last resort, perhaps load from page table, if $this->mPage is given?! return $this->mTitle; } @@ -753,14 +830,38 @@ class Revision { * @param $user User object to check for, only if FOR_THIS_USER is passed * to the $audience parameter * @return String + * @deprecated in 1.WD, use getContent() instead + * @todo: replace usage in core */ public function getText( $audience = self::FOR_PUBLIC, User $user = null ) { + wfDeprecated( __METHOD__, '1.WD' ); + + $content = $this->getContent( $audience, $user ); + return ContentHandler::getContentText( $content ); # returns the raw content text, if applicable + } + + /** + * Fetch revision content if it's available to the specified audience. + * If the specified audience does not have the ability to view this + * revision, null will be returned. + * + * @param $audience Integer: one of: + * Revision::FOR_PUBLIC to be displayed to all users + * Revision::FOR_THIS_USER to be displayed to $wgUser + * Revision::RAW get the text regardless of permissions + * @param $user User object to check for, only if FOR_THIS_USER is passed + * to the $audience parameter + * @return Content + * + * @since 1.WD + */ + public function getContent( $audience = self::FOR_PUBLIC, User $user = null ) { if( $audience == self::FOR_PUBLIC && $this->isDeleted( self::DELETED_TEXT ) ) { - return ''; + return null; } elseif( $audience == self::FOR_THIS_USER && !$this->userCan( self::DELETED_TEXT, $user ) ) { - return ''; + return null; } else { - return $this->getRawText(); + return $this->getContentInternal(); } } @@ -779,15 +880,107 @@ class Revision { * Fetch revision text without regard for view restrictions * * @return String + * + * @deprecated since 1.WD. Instead, use Revision::getContent( Revision::RAW ) or Revision::getSerializedData() as appropriate. */ public function getRawText() { - if( is_null( $this->mText ) ) { - // Revision text is immutable. Load on demand: - $this->mText = $this->loadText(); - } + wfDeprecated( __METHOD__, "1.WD" ); + + return $this->getText( self::RAW ); + } + + /** + * Fetch original serialized data without regard for view restrictions + * + * @return String + * + * @since 1.WD + */ + public function getSerializedData() { return $this->mText; } + protected function getContentInternal() { + if( is_null( $this->mContent ) ) { + // Revision is immutable. Load on demand: + + $handler = $this->getContentHandler(); + $format = $this->getContentFormat(); + $title = $this->getTitle(); + + if( is_null( $this->mText ) ) { + // Load text on demand: + $this->mText = $this->loadText(); + } + + $this->mContent = is_null( $this->mText ) ? null : $handler->unserializeContent( $this->mText, $format ); + } + + return $this->mContent; + } + + /** + * Returns the content model for this revision. + * + * If no content model was stored in the database, $this->getTitle()->getContentModel() is + * used to determine the content model to use. If no title is know, CONTENT_MODEL_WIKITEXT + * is used as a last resort. + * + * @return int the content model id associated with this revision, see the CONTENT_MODEL_XXX constants. + **/ + public function getContentModel() { + if ( !$this->mContentModel ) { + $title = $this->getTitle(); + $this->mContentModel = ( $title ? $title->getContentModel() : CONTENT_MODEL_WIKITEXT ); + + assert( !empty( $this->mContentModel ) ); + } + + return $this->mContentModel; + } + + /** + * Returns the content format for this revision. + * + * If no content format was stored in the database, the default format for this + * revision's content model is returned. + * + * @return int the content format id associated with this revision, see the CONTENT_FORMAT_XXX constants. + **/ + public function getContentFormat() { + if ( !$this->mContentFormat ) { + $handler = $this->getContentHandler(); + $this->mContentFormat = $handler->getDefaultFormat(); + + assert( !empty( $this->mContentFormat ) ); + } + + return $this->mContentFormat; + } + + /** + * Returns the content handler appropriate for this revision's content model. + * + * @return ContentHandler + */ + public function getContentHandler() { + if ( !$this->mContentHandler ) { + $model = $this->getContentModel(); + $this->mContentHandler = ContentHandler::getForModelID( $model ); + + $format = $this->getContentFormat(); + + if ( !$this->mContentHandler->isSupportedFormat( $format ) ) { + $formatName = ContentHandler::getContentFormatMimeType( $format ); + $modelName = ContentHandler::getContentModelName( $model ); + + throw new MWException( "Oops, the content format #$format ($formatName) is not supported for this content model, #$model ($modelName)" ); + } + } + + return $this->mContentHandler; + } + /** * @return String */ @@ -970,7 +1163,7 @@ class Revision { * @return Integer */ public function insertOn( $dbw ) { - global $wgDefaultExternalStore; + global $wgDefaultExternalStore, $wgContentHandlerUseDB; wfProfileIn( __METHOD__ ); @@ -1009,27 +1202,35 @@ class Revision { $rev_id = isset( $this->mId ) ? $this->mId : $dbw->nextSequenceValue( 'revision_rev_id_seq' ); - $dbw->insert( 'revision', - array( - 'rev_id' => $rev_id, - 'rev_page' => $this->mPage, - 'rev_text_id' => $this->mTextId, - 'rev_comment' => $this->mComment, - 'rev_minor_edit' => $this->mMinorEdit ? 1 : 0, - 'rev_user' => $this->mUser, - 'rev_user_text' => $this->mUserText, - 'rev_timestamp' => $dbw->timestamp( $this->mTimestamp ), - 'rev_deleted' => $this->mDeleted, - 'rev_len' => $this->mSize, - 'rev_parent_id' => is_null( $this->mParentId ) - ? $this->getPreviousRevisionId( $dbw ) - : $this->mParentId, - 'rev_sha1' => is_null( $this->mSha1 ) - ? Revision::base36Sha1( $this->mText ) - : $this->mSha1 - ), __METHOD__ + + $row = array( + 'rev_id' => $rev_id, + 'rev_page' => $this->mPage, + 'rev_text_id' => $this->mTextId, + 'rev_comment' => $this->mComment, + 'rev_minor_edit' => $this->mMinorEdit ? 1 : 0, + 'rev_user' => $this->mUser, + 'rev_user_text' => $this->mUserText, + 'rev_timestamp' => $dbw->timestamp( $this->mTimestamp ), + 'rev_deleted' => $this->mDeleted, + 'rev_len' => $this->mSize, + 'rev_parent_id' => is_null( $this->mParentId ) + ? $this->getPreviousRevisionId( $dbw ) + : $this->mParentId, + 'rev_sha1' => is_null( $this->mSha1 ) + ? Revision::base36Sha1( $this->mText ) + : $this->mSha1, ); + if ( $wgContentHandlerUseDB ) { + $row[ 'rev_content_model' ] = $this->getContentModel(); + $row[ 'rev_content_format' ] = $this->getContentFormat(); + } + + $this->checkContentModel(); + + $dbw->insert( 'revision', $row, __METHOD__ ); + $this->mId = !is_null( $rev_id ) ? $rev_id : $dbw->insertId(); wfRunHooks( 'RevisionInsertComplete', array( &$this, $data, $flags ) ); @@ -1038,6 +1239,57 @@ class Revision { return $this->mId; } + protected function checkContentModel() { + global $wgContentHandlerUseDB; + + $title = $this->getTitle(); //note: returns null for revisions that have not yet been inserted. + + $model = $this->getContentModel(); + $format = $this->getContentFormat(); + $handler = $this->getContentHandler(); + + if ( !$handler->isSupportedFormat( $format ) ) { + $t = $title->getPrefixedDBkey(); + $modelName = ContentHandler::getContentModelName( $model ); + $formatName = ContentHandler::getContentFormatMimeType( $format ); + + throw new MWException( "Can't use format #$format ($formatName) with content model #$model ($modelName) on $t" ); + } + + if ( !$wgContentHandlerUseDB && $title ) { + // if $wgContentHandlerUseDB is not set, all revisions must use the default content model and format. + + $defaultModel = ContentHandler::getDefaultModelFor( $title ); + $defaultHandler = ContentHandler::getForModelID( $defaultModel ); + $defaultFormat = $defaultHandler->getDefaultFormat(); + + if ( $this->getContentModel() != $defaultModel ) { + $defaultModelName = ContentHandler::getContentModelName( $defaultModel ); + $modelName = ContentHandler::getContentModelName( $model ); + $t = $title->getPrefixedDBkey(); + + throw new MWException( "Can't save non-default content model with \$wgContentHandlerUseDB disabled: model is #$model ($modelName), default for $t is #$defaultModel ($defaultModelName)" ); + } + + if ( $this->getContentFormat() != $defaultFormat ) { + $defaultFormatName = ContentHandler::getContentFormatMimeType( $defaultFormat ); + $formatName = ContentHandler::getContentFormatMimeType( $format ); + $t = $title->getPrefixedDBkey(); + + throw new MWException( "Can't use non-default content format with \$wgContentHandlerUseDB disabled: format is #$format ($formatName), default for $t is #$defaultFormat ($defaultFormatName)" ); + } + } + + $content = $this->getContent( Revision::RAW ); + + if ( !$content->isValid() ) { + $t = $title->getPrefixedDBkey(); + $modelName = ContentHandler::getContentModelName( $model ); + + throw new MWException( "Content of $t is not valid! Content model is #$model ($modelName)" ); + } + } + /** * Get the base 36 SHA-1 value for a string of text * @param $text String @@ -1122,12 +1374,21 @@ class Revision { * @return Revision|null on error */ public static function newNullRevision( $dbw, $pageId, $summary, $minor ) { + global $wgContentHandlerUseDB; + wfProfileIn( __METHOD__ ); + $fields = array( 'page_latest', 'page_namespace', 'page_title', + 'rev_text_id', 'rev_len', 'rev_sha1' ); + + if ( $wgContentHandlerUseDB ) { + $fields[] = 'rev_content_model'; + $fields[] = 'rev_content_format'; + } + $current = $dbw->selectRow( array( 'page', 'revision' ), - array( 'page_latest', 'page_namespace', 'page_title', - 'rev_text_id', 'rev_len', 'rev_sha1' ), + $fields, array( 'page_id' => $pageId, 'page_latest=rev_id', @@ -1135,7 +1396,7 @@ class Revision { __METHOD__ ); if( $current ) { - $revision = new Revision( array( + $row = array( 'page' => $pageId, 'comment' => $summary, 'minor_edit' => $minor, @@ -1143,7 +1404,14 @@ class Revision { 'parent_id' => $current->page_latest, 'len' => $current->rev_len, 'sha1' => $current->rev_sha1 - ) ); + ); + + if ( $wgContentHandlerUseDB ) { + $row[ 'content_model' ] = $current->rev_content_model; + $row[ 'content_format' ] = $current->rev_content_format; + } + + $revision = new Revision( $row ); $revision->setTitle( Title::makeTitle( $current->page_namespace, $current->page_title ) ); } else { $revision = null; diff --git a/includes/SqlDataUpdate.php b/includes/SqlDataUpdate.php index 6d36a439ed..a56a45473f 100644 --- a/includes/SqlDataUpdate.php +++ b/includes/SqlDataUpdate.php @@ -46,8 +46,6 @@ abstract class SqlDataUpdate extends DataUpdate { $this->mOptions = array( 'FOR UPDATE' ); } - // @todo: get connection only when it's needed? make sure that doesn't break anything, especially transactions! - $this->mDb = wfGetDB( DB_MASTER ); $this->mHasTransaction = false; } @@ -58,6 +56,8 @@ abstract class SqlDataUpdate extends DataUpdate { * checkes Database::trxLevel() and only opens a transaction if none is yet active. */ public function beginTransaction() { + $this->mDb = wfGetDB( DB_MASTER ); + // NOTE: nested transactions are not supported, only start a transaction if none is open if ( $this->mDb->trxLevel() === 0 ) { $this->mDb->begin( get_class( $this ) . '::beginTransaction' ); diff --git a/includes/Title.php b/includes/Title.php index 481f480c99..b0849db73a 100644 --- a/includes/Title.php +++ b/includes/Title.php @@ -65,6 +65,7 @@ class Title { var $mFragment; // /< Title fragment (i.e. the bit after the #) var $mArticleID = -1; // /< Article ID, fetched from the link cache on demand var $mLatestID = false; // /< ID of most recent revision + var $mContentModel = false; // /< ID of the page's content model, i.e. one of the CONTENT_MODEL_XXX constants private $mEstimateRevisions; // /< Estimated number of revisions; null of not loaded var $mRestrictions = array(); // /< Array of groups allowed to edit this article var $mOldRestrictions = false; @@ -200,6 +201,27 @@ class Title { } } + /** + * Returns a list of fields that are to be selected for initializing Title objects or LinkCache entries. + * Uses $wgContentHandlerUseDB to determine whether to include page_content_model. + * + * @return array + */ + protected static function getSelectFields() { + global $wgContentHandlerUseDB; + + $fields = array( + 'page_namespace', 'page_title', 'page_id', + 'page_len', 'page_is_redirect', 'page_latest', + ); + + if ( $wgContentHandlerUseDB ) { + $fields[] = 'page_content_model'; + } + + return $fields; + } + /** * Create a new Title from an article ID * @@ -211,10 +233,7 @@ class Title { $db = ( $flags & self::GAID_FOR_UPDATE ) ? wfGetDB( DB_MASTER ) : wfGetDB( DB_SLAVE ); $row = $db->selectRow( 'page', - array( - 'page_namespace', 'page_title', 'page_id', - 'page_len', 'page_is_redirect', 'page_latest', - ), + self::getSelectFields(), array( 'page_id' => $id ), __METHOD__ ); @@ -240,10 +259,7 @@ class Title { $res = $dbr->select( 'page', - array( - 'page_namespace', 'page_title', 'page_id', - 'page_len', 'page_is_redirect', 'page_latest', - ), + self::getSelectFields(), array( 'page_id' => $ids ), __METHOD__ ); @@ -283,11 +299,16 @@ class Title { $this->mRedirect = (bool)$row->page_is_redirect; if ( isset( $row->page_latest ) ) $this->mLatestID = (int)$row->page_latest; + if ( isset( $row->page_content_model ) ) + $this->mContentModel = intval( $row->page_content_model ); + else + $this->mContentModel = false; # initialized lazily in getContentModel() } else { // page not found $this->mArticleID = 0; $this->mLength = 0; $this->mRedirect = false; $this->mLatestID = 0; + $this->mContentModel = false; # initialized lazily in getContentModel() } } @@ -313,6 +334,7 @@ class Title { $t->mArticleID = ( $ns >= 0 ) ? -1 : 0; $t->mUrlform = wfUrlencode( $t->mDbkeyform ); $t->mTextform = str_replace( '_', ' ', $title ); + $t->mContentModel = false; # initialized lazily in getContentModel() return $t; } @@ -363,9 +385,11 @@ class Title { * * @param $text String: Text with possible redirect * @return Title: The corresponding Title + * @deprecated since 1.WD, use Content::getRedirectTarget instead. */ public static function newFromRedirect( $text ) { - return self::newFromRedirectInternal( $text ); + $content = ContentHandler::makeContent( $text, null, CONTENT_MODEL_WIKITEXT ); + return $content->getRedirectTarget(); } /** @@ -376,10 +400,11 @@ class Title { * * @param $text String Text with possible redirect * @return Title + * @deprecated since 1.WD, use Content::getUltimateRedirectTarget instead. */ public static function newFromRedirectRecurse( $text ) { - $titles = self::newFromRedirectArray( $text ); - return $titles ? array_pop( $titles ) : null; + $content = ContentHandler::makeContent( $text, null, CONTENT_MODEL_WIKITEXT ); + return $content->getUltimateRedirectTarget(); } /** @@ -390,71 +415,11 @@ class Title { * * @param $text String Text with possible redirect * @return Array of Titles, with the destination last + * @deprecated since 1.WD, use Content::getRedirectChain instead. */ public static function newFromRedirectArray( $text ) { - global $wgMaxRedirects; - $title = self::newFromRedirectInternal( $text ); - if ( is_null( $title ) ) { - return null; - } - // recursive check to follow double redirects - $recurse = $wgMaxRedirects; - $titles = array( $title ); - while ( --$recurse > 0 ) { - if ( $title->isRedirect() ) { - $page = WikiPage::factory( $title ); - $newtitle = $page->getRedirectTarget(); - } else { - break; - } - // Redirects to some special pages are not permitted - if ( $newtitle instanceOf Title && $newtitle->isValidRedirectTarget() ) { - // the new title passes the checks, so make that our current title so that further recursion can be checked - $title = $newtitle; - $titles[] = $newtitle; - } else { - break; - } - } - return $titles; - } - - /** - * Really extract the redirect destination - * Do not call this function directly, use one of the newFromRedirect* functions above - * - * @param $text String Text with possible redirect - * @return Title - */ - protected static function newFromRedirectInternal( $text ) { - global $wgMaxRedirects; - if ( $wgMaxRedirects < 1 ) { - //redirects are disabled, so quit early - return null; - } - $redir = MagicWord::get( 'redirect' ); - $text = trim( $text ); - if ( $redir->matchStartAndRemove( $text ) ) { - // Extract the first link and see if it's usable - // Ensure that it really does come directly after #REDIRECT - // Some older redirects included a colon, so don't freak about that! - $m = array(); - if ( preg_match( '!^\s*:?\s*\[{2}(.*?)(?:\|.*?)?\]{2}!', $text, $m ) ) { - // Strip preceding colon used to "escape" categories, etc. - // and URL-decode links - if ( strpos( $m[1], '%' ) !== false ) { - // Match behavior of inline link parsing here; - $m[1] = rawurldecode( ltrim( $m[1], ':' ) ); - } - $title = Title::newFromText( $m[1] ); - // If the title is a redirect to bad special pages or is invalid, return null - if ( !$title instanceof Title || !$title->isValidRedirectTarget() ) { - return null; - } - return $title; - } - } - return null; + $content = ContentHandler::makeContent( $text, null, CONTENT_MODEL_WIKITEXT ); + return $content->getRedirectChain(); } /** @@ -702,6 +667,38 @@ class Title { return $this->mNamespace; } + /** + * Get the page's content model id, see the CONTENT_MODEL_XXX constants. + * + * @return Integer: Content model id + */ + public function getContentModel() { + if ( !$this->mContentModel ) { + $linkCache = LinkCache::singleton(); + $this->mContentModel = $linkCache->getGoodLinkFieldObj( $this, 'model' ); + } + + if ( !$this->mContentModel ) { + $this->mContentModel = ContentHandler::getDefaultModelFor( $this ); + } + + if( !$this->mContentModel ) { + throw new MWException( "failed to determin content model!" ); + } + + return $this->mContentModel; + } + + /** + * Convenience method for checking a title's content model name + * + * @param int $id + * @return Boolean true if $this->getContentModel() == $id + */ + public function hasContentModel( $id ) { + return $this->getContentModel() == $id; + } + /** * Get the namespace text * @@ -945,22 +942,31 @@ class Title { * @return Bool */ public function isWikitextPage() { - $retval = !$this->isCssOrJsPage() && !$this->isCssJsSubpage(); - wfRunHooks( 'TitleIsWikitextPage', array( $this, &$retval ) ); - return $retval; + return $this->hasContentModel( CONTENT_MODEL_WIKITEXT ); } /** - * Could this page contain custom CSS or JavaScript, based - * on the title? + * Could this page contain custom CSS or JavaScript for the global UI. + * This is generally true for pages in the MediaWiki namespace having CONTENT_MODEL_CSS + * or CONTENT_MODEL_JAVASCRIPT. + * + * This method does *not* return true for per-user JS/CSS. Use isCssJsSubpage() for that! + * + * Note that this method should not return true for pages that contain and show "inactive" CSS or JS. * * @return Bool */ public function isCssOrJsPage() { - $retval = $this->mNamespace == NS_MEDIAWIKI - && preg_match( '!\.(?:css|js)$!u', $this->mTextform ) > 0; - wfRunHooks( 'TitleIsCssOrJsPage', array( $this, &$retval ) ); - return $retval; + $isCssOrJsPage = NS_MEDIAWIKI == $this->mNamespace + && ( $this->hasContentModel( CONTENT_MODEL_CSS ) + || $this->hasContentModel( CONTENT_MODEL_JAVASCRIPT ) ); + + #NOTE: this hook is also called in ContentHandler::getDefaultModel. It's called here again to make sure + # hook funktions can force this method to return true even outside the mediawiki namespace. + + wfRunHooks( 'TitleIsCssOrJsPage', array( $this, &$isCssOrJsPage ) ); + + return $isCssOrJsPage; } /** @@ -968,7 +974,9 @@ class Title { * @return Bool */ public function isCssJsSubpage() { - return ( NS_USER == $this->mNamespace and preg_match( "/\\/.*\\.(?:css|js)$/", $this->mTextform ) ); + return ( NS_USER == $this->mNamespace && $this->isSubpage() + && ( $this->hasContentModel( CONTENT_MODEL_CSS ) + || $this->hasContentModel( CONTENT_MODEL_JAVASCRIPT ) ) ); } /** @@ -991,7 +999,8 @@ class Title { * @return Bool */ public function isCssSubpage() { - return ( NS_USER == $this->mNamespace && preg_match( "/\\/.*\\.css$/", $this->mTextform ) ); + return ( NS_USER == $this->mNamespace && $this->isSubpage() + && $this->hasContentModel( CONTENT_MODEL_CSS ) ); } /** @@ -1000,7 +1009,8 @@ class Title { * @return Bool */ public function isJsSubpage() { - return ( NS_USER == $this->mNamespace && preg_match( "/\\/.*\\.js$/", $this->mTextform ) ); + return ( NS_USER == $this->mNamespace && $this->isSubpage() + && $this->hasContentModel( CONTENT_MODEL_JAVASCRIPT ) ); } /** @@ -2824,8 +2834,16 @@ class Title { if ( !$this->getArticleID( $flags ) ) { return $this->mRedirect = false; } + $linkCache = LinkCache::singleton(); - $this->mRedirect = (bool)$linkCache->getGoodLinkFieldObj( $this, 'redirect' ); + $cached = $linkCache->getGoodLinkFieldObj( $this, 'redirect' ); + if ( $cached === null ) { # check the assumption that the cache actually knows about this title + # XXX: this does apparently happen, see https://bugzilla.wikimedia.org/show_bug.cgi?id=37209 + # as a stop gap, perhaps log this, but don't throw an exception? + throw new MWException( "LinkCache doesn't currently know about this title: " . $this->getPrefixedDBkey() ); + } + + $this->mRedirect = (bool)$cached; return $this->mRedirect; } @@ -2846,7 +2864,14 @@ class Title { return $this->mLength = 0; } $linkCache = LinkCache::singleton(); - $this->mLength = intval( $linkCache->getGoodLinkFieldObj( $this, 'length' ) ); + $cached = $linkCache->getGoodLinkFieldObj( $this, 'length' ); + if ( $cached === null ) { # check the assumption that the cache actually knows about this title + # XXX: this does apparently happen, see https://bugzilla.wikimedia.org/show_bug.cgi?id=37209 + # as a stop gap, perhaps log this, but don't throw an exception? + throw new MWException( "LinkCache doesn't currently know about this title: " . $this->getPrefixedDBkey() ); + } + + $this->mLength = intval( $cached ); return $this->mLength; } @@ -2866,7 +2891,14 @@ class Title { return $this->mLatestID = 0; } $linkCache = LinkCache::singleton(); - $this->mLatestID = intval( $linkCache->getGoodLinkFieldObj( $this, 'revision' ) ); + $cached = $linkCache->getGoodLinkFieldObj( $this, 'revision' ); + if ( $cached === null ) { # check the assumption that the cache actually knows about this title + # XXX: this does apparently happen, see https://bugzilla.wikimedia.org/show_bug.cgi?id=37209 + # as a stop gap, perhaps log this, but don't throw an exception? + throw new MWException( "LinkCache doesn't currently know about this title: " . $this->getPrefixedDBkey() ); + } + + $this->mLatestID = intval( $cached ); return $this->mLatestID; } @@ -2895,6 +2927,7 @@ class Title { $this->mRedirect = null; $this->mLength = -1; $this->mLatestID = false; + $this->mContentModel = false; $this->mEstimateRevisions = null; } @@ -3133,7 +3166,7 @@ class Title { $res = $db->select( array( 'page', $table ), - array( 'page_namespace', 'page_title', 'page_id', 'page_len', 'page_is_redirect', 'page_latest' ), + self::getSelectFields(), array( "{$prefix}_from=page_id", "{$prefix}_namespace" => $this->getNamespace(), @@ -3183,6 +3216,8 @@ class Title { * @return Array of Title objects linking here */ public function getLinksFrom( $options = array(), $table = 'pagelinks', $prefix = 'pl' ) { + global $wgContentHandlerUseDB; + $id = $this->getArticleID(); # If the page doesn't exist; there can't be any link from this page @@ -3199,9 +3234,12 @@ class Title { $namespaceFiled = "{$prefix}_namespace"; $titleField = "{$prefix}_title"; + $fields = array( $namespaceFiled, $titleField, 'page_id', 'page_len', 'page_is_redirect', 'page_latest' ); + if ( $wgContentHandlerUseDB ) $fields[] = 'page_content_model'; + $res = $db->select( array( $table, 'page' ), - array( $namespaceFiled, $titleField, 'page_id', 'page_len', 'page_is_redirect', 'page_latest' ), + $fields, array( "{$prefix}_from" => $id ), __METHOD__, $options, @@ -3763,10 +3801,16 @@ class Title { * @return Bool */ public function isSingleRevRedirect() { + global $wgContentHandlerUseDB; + $dbw = wfGetDB( DB_MASTER ); + # Is it a redirect? + $fields = array( 'page_is_redirect', 'page_latest', 'page_id' ); + if ( $wgContentHandlerUseDB ) $fields[] = 'page_content_model'; + $row = $dbw->selectRow( 'page', - array( 'page_is_redirect', 'page_latest', 'page_id' ), + $fields, $this->pageCond(), __METHOD__, array( 'FOR UPDATE' ) @@ -3775,6 +3819,7 @@ class Title { $this->mArticleID = $row ? intval( $row->page_id ) : 0; $this->mRedirect = $row ? (bool)$row->page_is_redirect : false; $this->mLatestID = $row ? intval( $row->page_latest ) : false; + $this->mContentModel = $row && isset( $row->page_content_model ) ? intval( $row->page_content_model ) : false; if ( !$this->mRedirect ) { return false; } @@ -3819,24 +3864,24 @@ class Title { if( !is_object( $rev ) ){ return false; } - $text = $rev->getText(); + $content = $rev->getContent(); # Does the redirect point to the source? # Or is it a broken self-redirect, usually caused by namespace collisions? - $m = array(); - if ( preg_match( "/\\[\\[\\s*([^\\]\\|]*)]]/", $text, $m ) ) { - $redirTitle = Title::newFromText( $m[1] ); - if ( !is_object( $redirTitle ) || - ( $redirTitle->getPrefixedDBkey() != $this->getPrefixedDBkey() && - $redirTitle->getPrefixedDBkey() != $nt->getPrefixedDBkey() ) ) { + $redirTitle = $content->getRedirectTarget(); + + if ( $redirTitle ) { + if ( $redirTitle->getPrefixedDBkey() != $this->getPrefixedDBkey() && + $redirTitle->getPrefixedDBkey() != $nt->getPrefixedDBkey() ) { wfDebug( __METHOD__ . ": redirect points to other page\n" ); return false; + } else { + return true; } } else { - # Fail safe - wfDebug( __METHOD__ . ": failsafe\n" ); + # Fail safe (not a redirect after all. strange.) + wfDebug( __METHOD__ . ": failsafe: database sais " . $nt->getPrefixedDBkey() . " is a redirect, but it doesn't contain a valid redirect.\n" ); return false; } - return true; } /** diff --git a/includes/WikiFilePage.php b/includes/WikiFilePage.php index 9fb1522d73..0114cce902 100644 --- a/includes/WikiFilePage.php +++ b/includes/WikiFilePage.php @@ -41,7 +41,9 @@ class WikiFilePage extends WikiPage { } public function getActionOverrides() { - return array( 'revert' => 'RevertFileAction' ); + $overrides = parent::getActionOverrides(); + $overrides[ 'revert' ] = 'RevertFileAction'; + return $overrides; } /** @@ -103,13 +105,12 @@ class WikiFilePage extends WikiPage { } /** - * @param bool $text * @return bool */ - public function isRedirect( $text = false ) { + public function isRedirect( ) { $this->loadFile(); if ( $this->mFile->isLocal() ) { - return parent::isRedirect( $text ); + return parent::isRedirect(); } return (bool)$this->mFile->getRedirected(); diff --git a/includes/WikiPage.php b/includes/WikiPage.php index 0ba517a259..3910bf87f9 100644 --- a/includes/WikiPage.php +++ b/includes/WikiPage.php @@ -230,7 +230,21 @@ class WikiPage extends Page { * @return Array */ public function getActionOverrides() { - return array(); + $content_handler = $this->getContentHandler(); + return $content_handler->getActionOverrides(); + } + + /** + * Returns the ContentHandler instance to be used to deal with the content of this WikiPage. + * + * Shorthand for ContentHandler::getForModelID( $this->getContentModel() ); + * + * @return ContentHandler + * + * @since 1.WD + */ + public function getContentHandler() { + return ContentHandler::getForModelID( $this->getContentModel() ); } /** @@ -274,7 +288,9 @@ class WikiPage extends Page { * @return array */ public static function selectFields() { - return array( + global $wgContentHandlerUseDB; + + $fields = array( 'page_id', 'page_namespace', 'page_title', @@ -287,6 +303,12 @@ class WikiPage extends Page { 'page_latest', 'page_len', ); + + if ( $wgContentHandlerUseDB ) { + $fields[] = 'page_content_model'; + } + + return $fields; } /** @@ -461,21 +483,41 @@ class WikiPage extends Page { } /** - * Tests if the article text represents a redirect + * Tests if the article content represents a redirect * - * @param $text mixed string containing article contents, or boolean * @return bool */ - public function isRedirect( $text = false ) { - if ( $text === false ) { - if ( !$this->mDataLoaded ) { - $this->loadPageData(); - } + public function isRedirect( ) { + $content = $this->getContent(); + if ( !$content ) return false; - return (bool)$this->mIsRedirect; - } else { - return Title::newFromRedirect( $text ) !== null; + return $content->isRedirect(); + } + + /** + * Returns the page's content model id (see the CONTENT_MODEL_XXX constants). + * + * Will use the revisions actual content model if the page exists, + * and the page's default if the page doesn't exist yet. + * + * @return int + * + * @since 1.WD + */ + public function getContentModel() { + if ( $this->exists() ) { + # look at the revision's actual content model + $rev = $this->getRevision(); + + if ( $rev !== null ) { + return $rev->getContentModel(); + } else { + wfWarn( "Page exists but has no revision!" ); + } } + + # use the default model for this page + return $this->mTitle->getContentModel(); } /** @@ -590,6 +632,25 @@ class WikiPage extends Page { return null; } + /** + * Get the content of the current revision. No side-effects... + * + * @param $audience Integer: one of: + * Revision::FOR_PUBLIC to be displayed to all users + * Revision::FOR_THIS_USER to be displayed to $wgUser + * Revision::RAW get the text regardless of permissions + * @return Content|null The content of the current revision + * + * @since 1.WD + */ + public function getContent( $audience = Revision::FOR_PUBLIC ) { + $this->loadLastEdit(); + if ( $this->mLastRevision ) { + return $this->mLastRevision->getContent( $audience ); + } + return null; + } + /** * Get the text of the current revision. No side-effects... * @@ -597,9 +658,12 @@ class WikiPage extends Page { * Revision::FOR_PUBLIC to be displayed to all users * Revision::FOR_THIS_USER to be displayed to $wgUser * Revision::RAW get the text regardless of permissions - * @return String|bool The text of the current revision. False on failure + * @return String|false The text of the current revision + * @deprecated as of 1.WD, getContent() should be used instead. */ - public function getText( $audience = Revision::FOR_PUBLIC ) { + public function getText( $audience = Revision::FOR_PUBLIC ) { #@todo: deprecated, replace usage! + wfDeprecated( __METHOD__, '1.WD' ); + $this->loadLastEdit(); if ( $this->mLastRevision ) { return $this->mLastRevision->getText( $audience ); @@ -611,13 +675,12 @@ class WikiPage extends Page { * Get the text of the current revision. No side-effects... * * @return String|bool The text of the current revision. False on failure + * @deprecated as of 1.WD, getContent() should be used instead. */ public function getRawText() { - $this->loadLastEdit(); - if ( $this->mLastRevision ) { - return $this->mLastRevision->getRawText(); - } - return false; + wfDeprecated( __METHOD__, '1.WD' ); + + return $this->getText( Revision::RAW ); } /** @@ -759,32 +822,34 @@ class WikiPage extends Page { return false; } - $text = $editInfo ? $editInfo->pst : false; + if ( $editInfo ) { + $content = $editInfo->pstContent; + } else { + $content = $this->getContent(); + } - if ( $this->isRedirect( $text ) ) { + if ( !$content || $content->isRedirect( ) ) { return false; } - switch ( $wgArticleCountMethod ) { - case 'any': - return true; - case 'comma': - if ( $text === false ) { - $text = $this->getRawText(); - } - return strpos( $text, ',' ) !== false; - case 'link': + $hasLinks = null; + + if ( $wgArticleCountMethod === 'link' ) { + # nasty special case to avoid re-parsing to detect links + if ( $editInfo ) { // ParserOutput::getLinks() is a 2D array of page links, so // to be really correct we would need to recurse in the array // but the main array should only have items in it if there are // links. - return (bool)count( $editInfo->output->getLinks() ); + $hasLinks = (bool)count( $editInfo->output->getLinks() ); } else { - return (bool)wfGetDB( DB_SLAVE )->selectField( 'pagelinks', 1, + $hasLinks = (bool)wfGetDB( DB_SLAVE )->selectField( 'pagelinks', 1, array( 'pl_from' => $this->getId() ), __METHOD__ ); } } + + return $content->isCountable( $hasLinks ); } /** @@ -830,7 +895,8 @@ class WikiPage extends Page { */ public function insertRedirect() { // recurse through to only get the final target - $retval = Title::newFromRedirectRecurse( $this->getRawText() ); + $content = $this->getContent(); + $retval = $content ? $content->getUltimateRedirectTarget() : null; if ( !$retval ) { return null; } @@ -1026,7 +1092,7 @@ class WikiPage extends Page { && $parserOptions->getStubThreshold() == 0 && $this->mTitle->exists() && ( $oldid === null || $oldid === 0 || $oldid === $this->getLatest() ) - && $this->mTitle->isWikitextPage(); + && $this->getContentHandler()->isParserCacheSupported(); } /** @@ -1037,6 +1103,7 @@ class WikiPage extends Page { * @param $parserOptions ParserOptions to use for the parse operation * @param $oldid Revision ID to get the text from, passing null or 0 will * get the current revision (default value) + * * @return ParserOutput or false if the revision was not found */ public function getParserOutput( ParserOptions $parserOptions, $oldid = null ) { @@ -1114,8 +1181,16 @@ class WikiPage extends Page { } if ( $this->mTitle->getNamespace() == NS_MEDIAWIKI ) { + //@todo: move this logic to MessageCache + if ( $this->mTitle->exists() ) { - $text = $this->getRawText(); + // NOTE: use transclusion text for messages. + // This is consistent with MessageCache::getMsgFromNamespace() + + $content = $this->getContent(); + $text = $content === null ? null : $content->getWikitextForTransclusion(); + + if ( $text === null ) $text = false; } else { $text = false; } @@ -1180,11 +1255,13 @@ class WikiPage extends Page { * @private */ public function updateRevisionOn( $dbw, $revision, $lastRevision = null, $lastRevIsRedirect = null ) { + global $wgContentHandlerUseDB; + wfProfileIn( __METHOD__ ); - $text = $revision->getText(); - $len = strlen( $text ); - $rt = Title::newFromRedirectRecurse( $text ); + $content = $revision->getContent(); + $len = $content->getSize(); + $rt = $content->getUltimateRedirectTarget(); $conditions = array( 'page_id' => $this->getId() ); @@ -1194,14 +1271,20 @@ class WikiPage extends Page { } $now = wfTimestampNow(); + $row = array( /* SET */ + 'page_latest' => $revision->getId(), + 'page_touched' => $dbw->timestamp( $now ), + 'page_is_new' => ( $lastRevision === 0 ) ? 1 : 0, + 'page_is_redirect' => $rt !== null ? 1 : 0, + 'page_len' => $len, + ); + + if ( $wgContentHandlerUseDB ) { + $row[ 'page_content_model' ] = $revision->getContentModel(); + } + $dbw->update( 'page', - array( /* SET */ - 'page_latest' => $revision->getId(), - 'page_touched' => $dbw->timestamp( $now ), - 'page_is_new' => ( $lastRevision === 0 ) ? 1 : 0, - 'page_is_redirect' => $rt !== null ? 1 : 0, - 'page_len' => $len, - ), + $row, $conditions, __METHOD__ ); @@ -1213,7 +1296,7 @@ class WikiPage extends Page { $this->mLatest = $revision->getId(); $this->mIsRedirect = (bool)$rt; # Update the LinkCache. - LinkCache::singleton()->addGoodLinkObj( $this->getId(), $this->mTitle, $len, $this->mIsRedirect, $this->mLatest ); + LinkCache::singleton()->addGoodLinkObj( $this->getId(), $this->mTitle, $len, $this->mIsRedirect, $this->mLatest, $revision->getContentModel() ); } wfProfileOut( __METHOD__ ); @@ -1303,27 +1386,29 @@ class WikiPage extends Page { * @param $undo Revision * @param $undoafter Revision Must be an earlier revision than $undo * @return mixed string on success, false on failure + * @deprecated since 1.WD: use ContentHandler::getUndoContent() instead. */ public function getUndoText( Revision $undo, Revision $undoafter = null ) { - $cur_text = $this->getRawText(); - if ( $cur_text === false ) { - return false; // no page - } - $undo_text = $undo->getText(); - $undoafter_text = $undoafter->getText(); + wfDeprecated( __METHOD__, '1.WD' ); - if ( $cur_text == $undo_text ) { - # No use doing a merge if it's just a straight revert. - return $undoafter_text; - } + $this->loadLastEdit(); + + if ( $this->mLastRevision ) { + if ( is_null( $undoafter ) ) { + $undoafter = $undo->getPrevious(); + } - $undone_text = ''; + $handler = $this->getContentHandler(); + $undone = $handler->getUndoContent( $this->mLastRevision, $undo, $undoafter ); - if ( !wfMerge( $undo_text, $undoafter_text, $cur_text, $undone_text ) ) { - return false; + if ( !$undone ) { + return false; + } else { + return ContentHandler::getContentText( $undone ); + } } - return $undone_text; + return false; } /** @@ -1331,18 +1416,62 @@ class WikiPage extends Page { * @param $text String: new text of the section * @param $sectionTitle String: new section's subject, only if $section is 'new' * @param $edittime String: revision timestamp or null to use the current revision - * @return string Complete article text, or null if error + * @return String new complete article text, or null if error + * + * @deprecated since 1.WD, use replaceSectionContent() instead */ public function replaceSection( $section, $text, $sectionTitle = '', $edittime = null ) { + wfDeprecated( __METHOD__, '1.WD' ); + + if ( !$this->supportsSections() ) { + return null; + } + + $sectionContent = ContentHandler::makeContent( $text, $this->getTitle() ); # could even make section title, but that's not required. + + $newContent = $this->replaceSectionContent( $section, $sectionContent, $sectionTitle, $edittime ); + + return ContentHandler::getContentText( $newContent ); + } + + /** + * Returns true iff this page's content model supports sections. + * + * @return boolean whether sections are supported. + * + * @todo: the skin should check this and not offer section functionality if sections are not supported. + * @todo: the EditPage should check this and not offer section functionality if sections are not supported. + */ + public function supportsSections() { + return $this->getContentHandler()->supportsSections(); + } + + /** + * @param $section null|bool|int or a section number (0, 1, 2, T1, T2...) + * @param $content Content: new content of the section + * @param $sectionTitle String: new section's subject, only if $section is 'new' + * @param $edittime String: revision timestamp or null to use the current revision + * + * @return Content new complete article content, or null if error + * + * @since 1.WD + */ + public function replaceSectionContent( $section, Content $sectionContent, $sectionTitle = '', $edittime = null ) { wfProfileIn( __METHOD__ ); + if ( !$this->supportsSections() ) { + #XXX: log this? + return null; + } + if ( strval( $section ) == '' ) { // Whole-page edit; let the whole text through + $newContent = $sectionContent; } else { // Bug 30711: always use current version when adding a new section if ( is_null( $edittime ) || $section == 'new' ) { - $oldtext = $this->getRawText(); - if ( $oldtext === false ) { + $oldContent = $this->getContent(); + if ( ! $oldContent ) { wfDebug( __METHOD__ . ": no page text\n" ); wfProfileOut( __METHOD__ ); return null; @@ -1358,27 +1487,14 @@ class WikiPage extends Page { return null; } - $oldtext = $rev->getText(); + $oldContent = $rev->getContent(); } - if ( $section == 'new' ) { - # Inserting a new section - $subject = $sectionTitle ? wfMsgForContent( 'newsectionheaderdefaultlevel', $sectionTitle ) . "\n\n" : ''; - if ( wfRunHooks( 'PlaceNewSection', array( $this, $oldtext, $subject, &$text ) ) ) { - $text = strlen( trim( $oldtext ) ) > 0 - ? "{$oldtext}\n\n{$subject}{$text}" - : "{$subject}{$text}"; - } - } else { - # Replacing an existing section; roll out the big guns - global $wgParser; - - $text = $wgParser->replaceSection( $oldtext, $section, $text ); - } + $newContent = $oldContent->replaceSection( $section, $sectionContent, $sectionTitle ); } wfProfileOut( __METHOD__ ); - return $text; + return $newContent; } /** @@ -1443,8 +1559,66 @@ class WikiPage extends Page { * revision: The revision object for the inserted revision, or null * * Compatibility note: this function previously returned a boolean value indicating success/failure + * + * @deprecated since 1.WD: use doEditContent() instead. */ - public function doEdit( $text, $summary, $flags = 0, $baseRevId = false, $user = null ) { + public function doEdit( $text, $summary, $flags = 0, $baseRevId = false, $user = null ) { #@todo: use doEditContent() instead + wfDeprecated( __METHOD__, '1.WD' ); + + $content = ContentHandler::makeContent( $text, $this->getTitle() ); + + return $this->doEditContent( $content, $summary, $flags, $baseRevId, $user ); + } + + /** + * Change an existing article or create a new article. Updates RC and all necessary caches, + * optionally via the deferred update array. + * + * @param $content Content: new content + * @param $summary String: edit summary + * @param $flags Integer bitfield: + * EDIT_NEW + * Article is known or assumed to be non-existent, create a new one + * EDIT_UPDATE + * Article is known or assumed to be pre-existing, update it + * EDIT_MINOR + * Mark this edit minor, if the user is allowed to do so + * EDIT_SUPPRESS_RC + * Do not log the change in recentchanges + * EDIT_FORCE_BOT + * Mark the edit a "bot" edit regardless of user rights + * EDIT_DEFER_UPDATES + * Defer some of the updates until the end of index.php + * EDIT_AUTOSUMMARY + * Fill in blank summaries with generated text where possible + * + * If neither EDIT_NEW nor EDIT_UPDATE is specified, the status of the article will be detected. + * If EDIT_UPDATE is specified and the article doesn't exist, the function will return an + * edit-gone-missing error. If EDIT_NEW is specified and the article does exist, an + * edit-already-exists error will be returned. These two conditions are also possible with + * auto-detection due to MediaWiki's performance-optimised locking strategy. + * + * @param $baseRevId the revision ID this edit was based off, if any + * @param $user User the user doing the edit + * @param $serialisation_format String: format for storing the content in the database + * + * @return Status object. Possible errors: + * edit-hook-aborted: The ArticleSave hook aborted the edit but didn't set the fatal flag of $status + * edit-gone-missing: In update mode, but the article didn't exist + * edit-conflict: In update mode, the article changed unexpectedly + * edit-no-change: Warning that the text was the same as before + * edit-already-exists: In creation mode, but the article already exists + * + * Extensions may define additional errors. + * + * $return->value will contain an associative array with members as follows: + * new: Boolean indicating if the function attempted to create a new article + * revision: The revision object for the inserted revision, or null + * + * @since 1.WD + */ + public function doEditContent( Content $content, $summary, $flags = 0, $baseRevId = false, + User $user = null, $serialisation_format = null ) { global $wgUser, $wgDBtransactions, $wgUseAutomaticEditSummaries; # Low-level sanity check @@ -1464,10 +1638,25 @@ class WikiPage extends Page { $flags = $this->checkFlags( $flags ); - if ( !wfRunHooks( 'ArticleSave', array( &$this, &$user, &$text, &$summary, - $flags & EDIT_MINOR, null, null, &$flags, &$status ) ) ) - { - wfDebug( __METHOD__ . ": ArticleSave hook aborted save!\n" ); + # call legacy hook + $hook_ok = wfRunHooks( 'ArticleContentSave', array( &$this, &$user, &$content, &$summary, + $flags & EDIT_MINOR, null, null, &$flags, &$status ) ); + + if ( $hook_ok && Hooks::isRegistered( 'ArticleSave' ) ) { # avoid serialization overhead if the hook isn't present + $content_text = $content->serialize(); + $txt = $content_text; # clone + + $hook_ok = wfRunHooks( 'ArticleSave', array( &$this, &$user, &$txt, &$summary, + $flags & EDIT_MINOR, null, null, &$flags, &$status ) ); #TODO: survey extensions using this hook + + if ( $txt !== $content_text ) { + # if the text changed, unserialize the new version to create an updated Content object. + $content = $content->getContentHandler()->unserializeContent( $txt ); + } + } + + if ( !$hook_ok ) { + wfDebug( __METHOD__ . ": ArticleSave or ArticleSaveContent hook aborted save!\n" ); if ( $status->isOK() ) { $status->fatal( 'edit-hook-aborted' ); @@ -1481,20 +1670,25 @@ class WikiPage extends Page { $isminor = ( $flags & EDIT_MINOR ) && $user->isAllowed( 'minoredit' ); $bot = $flags & EDIT_FORCE_BOT; - $oldtext = $this->getRawText(); // current revision - $oldsize = strlen( $oldtext ); + $old_content = $this->getContent( Revision::RAW ); // current revision's content + + $oldsize = $old_content ? $old_content->getSize() : 0; $oldid = $this->getLatest(); $oldIsRedirect = $this->isRedirect(); $oldcountable = $this->isCountable(); + $handler = $content->getContentHandler(); + # Provide autosummaries if one is not provided and autosummaries are enabled. if ( $wgUseAutomaticEditSummaries && $flags & EDIT_AUTOSUMMARY && $summary == '' ) { - $summary = self::getAutosummary( $oldtext, $text, $flags ); + if ( !$old_content ) $old_content = null; + $summary = $handler->getAutosummary( $old_content, $content, $flags ); } - $editInfo = $this->prepareTextForEdit( $text, null, $user ); - $text = $editInfo->pst; - $newsize = strlen( $text ); + $editInfo = $this->prepareContentForEdit( $content, null, $user, $serialisation_format ); + $serialized = $editInfo->pst; + $content = $editInfo->pstContent; + $newsize = $content->getSize(); $dbw = wfGetDB( DB_MASTER ); $now = wfTimestampNow(); @@ -1522,16 +1716,23 @@ class WikiPage extends Page { 'page' => $this->getId(), 'comment' => $summary, 'minor_edit' => $isminor, - 'text' => $text, + 'text' => $serialized, + 'len' => $newsize, 'parent_id' => $oldid, 'user' => $user->getId(), 'user_text' => $user->getName(), - 'timestamp' => $now - ) ); + 'timestamp' => $now, + 'content_model' => $content->getModel(), + 'content_format' => $serialisation_format, + ) ); #XXX: pass content object?! - $changed = ( strcmp( $text, $oldtext ) != 0 ); + $changed = !$content->equals( $old_content ); if ( $changed ) { + if ( !$content->isValid() ) { + throw new MWException( "New content failed validity check!" ); + } + $dbw->begin( __METHOD__ ); $revisionId = $revision->insertOn( $dbw ); @@ -1594,8 +1795,14 @@ class WikiPage extends Page { } # Update links tables, site stats, etc. - $this->doEditUpdates( $revision, $user, array( 'changed' => $changed, - 'oldcountable' => $oldcountable ) ); + $this->doEditUpdates( + $revision, + $user, + array( + 'changed' => $changed, + 'oldcountable' => $oldcountable + ) + ); if ( !$changed ) { $status->warning( 'edit-no-change' ); @@ -1627,10 +1834,13 @@ class WikiPage extends Page { 'page' => $newid, 'comment' => $summary, 'minor_edit' => $isminor, - 'text' => $text, + 'text' => $serialized, + 'len' => $newsize, 'user' => $user->getId(), 'user_text' => $user->getName(), - 'timestamp' => $now + 'timestamp' => $now, + 'content_model' => $content->getModel(), + 'content_format' => $serialisation_format, ) ); $revisionId = $revision->insertOn( $dbw ); @@ -1648,7 +1858,7 @@ class WikiPage extends Page { $this->mTitle->getUserPermissionsErrors( 'autopatrol', $user ) ); # Add RC row to the DB $rc = RecentChange::notifyNew( $now, $this->mTitle, $isminor, $user, $summary, $bot, - '', strlen( $text ), $revisionId, $patrolled ); + '', $content->getSize(), $revisionId, $patrolled ); # Log auto-patrolled edits if ( $patrolled ) { @@ -1661,7 +1871,10 @@ class WikiPage extends Page { # Update links, etc. $this->doEditUpdates( $revision, $user, array( 'created' => true ) ); - wfRunHooks( 'ArticleInsertComplete', array( &$this, &$user, $text, $summary, + wfRunHooks( 'ArticleInsertComplete', array( &$this, &$user, $serialized, $summary, + $flags & EDIT_MINOR, null, null, &$flags, $revision ) ); + + wfRunHooks( 'ArticleContentInsertComplete', array( &$this, &$user, $content, $summary, $flags & EDIT_MINOR, null, null, &$flags, $revision ) ); } @@ -1673,7 +1886,10 @@ class WikiPage extends Page { // Return the new revision (or null) to the caller $status->value['revision'] = $revision; - wfRunHooks( 'ArticleSaveComplete', array( &$this, &$user, $text, $summary, + wfRunHooks( 'ArticleSaveComplete', array( &$this, &$user, $serialized, $summary, + $flags & EDIT_MINOR, null, null, &$flags, $revision, &$status, $baseRevId ) ); + + wfRunHooks( 'ArticleContentSaveComplete', array( &$this, &$user, $content, $summary, $flags & EDIT_MINOR, null, null, &$flags, $revision, &$status, $baseRevId ) ); # Promote user to any groups they meet the criteria for @@ -1703,15 +1919,39 @@ class WikiPage extends Page { /** * Prepare text which is about to be saved. * Returns a stdclass with source, pst and output members - * @return bool|object + * + * @deprecated in 1.WD: use prepareContentForEdit instead. */ public function prepareTextForEdit( $text, $revid = null, User $user = null ) { + wfDeprecated( __METHOD__, '1.WD' ); + $content = ContentHandler::makeContent( $text, $this->getTitle() ); + return $this->prepareContentForEdit( $content, $revid , $user ); + } + + /** + * Prepare content which is about to be saved. + * Returns a stdclass with source, pst and output members + * + * @param \Content $content + * @param null $revid + * @param null|\User $user + * @param null $serialization_format + * + * @return bool|object + * + * @since 1.WD + */ + public function prepareContentForEdit( Content $content, $revid = null, User $user = null, $serialization_format = null ) { global $wgParser, $wgContLang, $wgUser; $user = is_null( $user ) ? $wgUser : $user; // @TODO fixme: check $user->getId() here??? + if ( $this->mPreparedEdit - && $this->mPreparedEdit->newText == $text + && $this->mPreparedEdit->newContent + && $this->mPreparedEdit->newContent->equals( $content ) && $this->mPreparedEdit->revid == $revid + && $this->mPreparedEdit->format == $serialization_format + #XXX: also check $user here? ) { // Already prepared return $this->mPreparedEdit; @@ -1722,11 +1962,21 @@ class WikiPage extends Page { $edit = (object)array(); $edit->revid = $revid; - $edit->newText = $text; - $edit->pst = $wgParser->preSaveTransform( $text, $this->mTitle, $user, $popts ); + + $edit->pstContent = $content->preSaveTransform( $this->mTitle, $user, $popts ); + $edit->pst = $edit->pstContent->serialize( $serialization_format ); #XXX: do we need this?? + $edit->format = $serialization_format; + $edit->popts = $this->makeParserOptions( 'canonical' ); - $edit->output = $wgParser->parse( $edit->pst, $this->mTitle, $edit->popts, true, true, $revid ); - $edit->oldText = $this->getRawText(); + + $edit->output = $edit->pstContent->getParserOutput( $this->mTitle, $revid, $edit->popts ); + + $edit->newContent = $content; + $edit->oldContent = $this->getContent( Revision::RAW ); + + #NOTE: B/C for hooks! don't use these fields! + $edit->newText = ContentHandler::getContentText( $edit->newContent ); + $edit->oldText = $edit->oldContent ? ContentHandler::getContentText( $edit->oldContent ) : ''; $this->mPreparedEdit = $edit; @@ -1739,7 +1989,6 @@ class WikiPage extends Page { * Purges pages that include this page if the text was changed here. * Every 100th edit, prune the recent changes table. * - * @private * @param $revision Revision object * @param $user User object that did the revision * @param $options Array of options, following indexes are used: @@ -1756,13 +2005,13 @@ class WikiPage extends Page { wfProfileIn( __METHOD__ ); $options += array( 'changed' => true, 'created' => false, 'oldcountable' => null ); - $text = $revision->getText(); + $content = $revision->getContent(); # Parse the text # Be careful not to double-PST: $text is usually already PST-ed once if ( !$this->mPreparedEdit || $this->mPreparedEdit->output->getFlag( 'vary-revision' ) ) { wfDebug( __METHOD__ . ": No prepared edit or vary-revision is set...\n" ); - $editInfo = $this->prepareTextForEdit( $text, $revision->getId(), $user ); + $editInfo = $this->prepareContentForEdit( $content, $revision->getId(), $user ); } else { wfDebug( __METHOD__ . ": No vary-revision, using prepared edit...\n" ); $editInfo = $this->mPreparedEdit; @@ -1775,7 +2024,8 @@ class WikiPage extends Page { } # Update the links tables and other secondary data - $updates = $editInfo->output->getSecondaryDataUpdates( $this->mTitle ); + $contentHandler = $revision->getContentHandler(); + $updates = $contentHandler->getSecondaryDataUpdates( $content, $this->getTitle(), null, true, $editInfo->output ); DataUpdate::runUpdates( $updates ); wfRunHooks( 'ArticleEditUpdates', array( &$this, &$editInfo, $options['changed'] ) ); @@ -1820,7 +2070,7 @@ class WikiPage extends Page { } DeferredUpdates::addUpdate( new SiteStatsUpdate( 0, 1, $good, $total ) ); - DeferredUpdates::addUpdate( new SearchUpdate( $id, $title, $text ) ); + DeferredUpdates::addUpdate( new SearchUpdate( $id, $title, $content->getTextForSearchIndex() ) ); #TODO: let the search engine decide what to do with the content object # If this is another user's talk page, update newtalk. # Don't do this if $options['changed'] = false (null-edits) nor if @@ -1846,7 +2096,10 @@ class WikiPage extends Page { } if ( $this->mTitle->getNamespace() == NS_MEDIAWIKI ) { - MessageCache::singleton()->replace( $shortTitle, $text ); + $msgtext = $content->getWikitextForTransclusion(); #XXX: could skip pseudo-messages like js/css here, based on content model. + if ( $msgtext === false || $msgtext === null ) $msgtext = ''; + + MessageCache::singleton()->replace( $shortTitle, $msgtext ); } if( $options['created'] ) { @@ -1867,17 +2120,40 @@ class WikiPage extends Page { * @param $user User The relevant user * @param $comment String: comment submitted * @param $minor Boolean: whereas it's a minor modification + * + * @deprecated since 1.WD, use doEditContent() instead. */ public function doQuickEdit( $text, User $user, $comment = '', $minor = 0 ) { + wfDeprecated( __METHOD__, "1.WD" ); + + $content = ContentHandler::makeContent( $text, $this->getTitle() ); + return $this->doQuickEditContent( $content, $user, $comment , $minor ); + } + + /** + * Edit an article without doing all that other stuff + * The article must already exist; link tables etc + * are not updated, caches are not flushed. + * + * @param $content Content: content submitted + * @param $user User The relevant user + * @param $comment String: comment submitted + * @param $serialisation_format String: format for storing the content in the database + * @param $minor Boolean: whereas it's a minor modification + */ + public function doQuickEditContent( Content $content, User $user, $comment = '', $minor = 0, $serialisation_format = null ) { wfProfileIn( __METHOD__ ); + $serialized = $content->serialize( $serialisation_format ); + $dbw = wfGetDB( DB_MASTER ); $revision = new Revision( array( 'page' => $this->getId(), - 'text' => $text, + 'text' => $serialized, + 'length' => $content->getSize(), 'comment' => $comment, 'minor_edit' => $minor ? 1 : 0, - ) ); + ) ); #XXX: set the content object? $revision->insertOn( $dbw ); $this->updateRevisionOn( $dbw, $revision ); @@ -2160,7 +2436,7 @@ class WikiPage extends Page { public function doDeleteArticleReal( $reason, $suppress = false, $id = 0, $commit = true, &$error = '', User $user = null ) { - global $wgUser; + global $wgUser, $wgContentHandlerUseDB; wfDebug( __METHOD__ . "\n" ); @@ -2193,6 +2469,9 @@ class WikiPage extends Page { $bitfield = 'rev_deleted'; } + // we need to remember the old content so we can use it to generate all deletion updates. + $content = $this->getContent( Revision::RAW ); + $dbw = wfGetDB( DB_MASTER ); $dbw->begin( __METHOD__ ); // For now, shunt the revision data into the archive table. @@ -2205,25 +2484,34 @@ class WikiPage extends Page { // // In the future, we may keep revisions and mark them with // the rev_deleted field, which is reserved for this purpose. + + $row = array( + 'ar_namespace' => 'page_namespace', + 'ar_title' => 'page_title', + 'ar_comment' => 'rev_comment', + 'ar_user' => 'rev_user', + 'ar_user_text' => 'rev_user_text', + 'ar_timestamp' => 'rev_timestamp', + 'ar_minor_edit' => 'rev_minor_edit', + 'ar_rev_id' => 'rev_id', + 'ar_parent_id' => 'rev_parent_id', + 'ar_text_id' => 'rev_text_id', + 'ar_text' => '\'\'', // Be explicit to appease + 'ar_flags' => '\'\'', // MySQL's "strict mode"... + 'ar_len' => 'rev_len', + 'ar_page_id' => 'page_id', + 'ar_deleted' => $bitfield, + 'ar_sha1' => 'rev_sha1', + ); + + if ( $wgContentHandlerUseDB ) { + $row[ 'ar_content_model' ] = 'rev_content_model'; + $row[ 'ar_content_format' ] = 'rev_content_format'; + } + $dbw->insertSelect( 'archive', array( 'page', 'revision' ), + $row, array( - 'ar_namespace' => 'page_namespace', - 'ar_title' => 'page_title', - 'ar_comment' => 'rev_comment', - 'ar_user' => 'rev_user', - 'ar_user_text' => 'rev_user_text', - 'ar_timestamp' => 'rev_timestamp', - 'ar_minor_edit' => 'rev_minor_edit', - 'ar_rev_id' => 'rev_id', - 'ar_parent_id' => 'rev_parent_id', - 'ar_text_id' => 'rev_text_id', - 'ar_text' => '\'\'', // Be explicit to appease - 'ar_flags' => '\'\'', // MySQL's "strict mode"... - 'ar_len' => 'rev_len', - 'ar_page_id' => 'page_id', - 'ar_deleted' => $bitfield, - 'ar_sha1' => 'rev_sha1' - ), array( 'page_id' => $id, 'page_id = rev_page' ), __METHOD__ @@ -2238,7 +2526,7 @@ class WikiPage extends Page { return WikiPage::DELETE_NO_REVISIONS; } - $this->doDeleteUpdates( $id ); + $this->doDeleteUpdates( $id, $content ); # Log the deletion, if the page was suppressed, log it at Oversight instead $logtype = $suppress ? 'suppress' : 'delete'; @@ -2262,13 +2550,15 @@ class WikiPage extends Page { * Do some database updates after deletion * * @param $id Int: page_id value of the page being deleted (B/C, currently unused) + * @param $content Content: optional page content to be used when determining the required updates. + * This may be needed because $this->getContent() may already return null when the page proper was deleted. */ - public function doDeleteUpdates( $id ) { + public function doDeleteUpdates( $id, Content $content = null ) { # update site status DeferredUpdates::addUpdate( new SiteStatsUpdate( 0, 1, - (int)$this->isCountable(), -1 ) ); # remove secondary indexes, etc - $updates = $this->getDeletionUpdates( ); + $updates = $this->getDeletionUpdates( $content ); DataUpdate::runUpdates( $updates ); # Clear caches @@ -2281,16 +2571,6 @@ class WikiPage extends Page { $this->mTitle->resetArticleID( 0 ); } - public function getDeletionUpdates() { - $updates = array( - new LinksDeletionUpdate( $this ), - ); - - //@todo: make a hook to add update objects - //NOTE: deletion updates will be determined by the ContentHandler in the future - return $updates; - } - /** * Roll back the most recent consecutive set of edits to a page * from the same user; fails if there are no eligible edits to @@ -2456,7 +2736,7 @@ class WikiPage extends Page { } # Actually store the edit - $status = $this->doEdit( $target->getText(), $summary, $flags, $target->getId(), $guser ); + $status = $this->doEditContent( $target->getContent(), $summary, $flags, $target->getId(), $guser ); if ( !empty( $status->value['revision'] ) ) { $revId = $status->value['revision']->getId(); } else { @@ -2602,57 +2882,23 @@ class WikiPage extends Page { /** * Return an applicable autosummary if one exists for the given edit. - * @param $oldtext String: the previous text of the page. - * @param $newtext String: The submitted text of the page. + * @param $oldtext String|null: the previous text of the page. + * @param $newtext String|null: The submitted text of the page. * @param $flags Int bitmask: a bitmask of flags submitted for the edit. * @return string An appropriate autosummary, or an empty string. + * + * @deprecated since 1.WD, use ContentHandler::getAutosummary() instead */ public static function getAutosummary( $oldtext, $newtext, $flags ) { - global $wgContLang; - - # Decide what kind of autosummary is needed. - - # Redirect autosummaries - $ot = Title::newFromRedirect( $oldtext ); - $rt = Title::newFromRedirect( $newtext ); - - if ( is_object( $rt ) && ( !is_object( $ot ) || !$rt->equals( $ot ) || $ot->getFragment() != $rt->getFragment() ) ) { - $truncatedtext = $wgContLang->truncate( - str_replace( "\n", ' ', $newtext ), - max( 0, 250 - - strlen( wfMsgForContent( 'autoredircomment' ) ) - - strlen( $rt->getFullText() ) - ) ); - return wfMsgForContent( 'autoredircomment', $rt->getFullText(), $truncatedtext ); - } - - # New page autosummaries - if ( $flags & EDIT_NEW && strlen( $newtext ) ) { - # If they're making a new article, give its text, truncated, in the summary. - - $truncatedtext = $wgContLang->truncate( - str_replace( "\n", ' ', $newtext ), - max( 0, 200 - strlen( wfMsgForContent( 'autosumm-new' ) ) ) ); - - return wfMsgForContent( 'autosumm-new', $truncatedtext ); - } - - # Blanking autosummaries - if ( $oldtext != '' && $newtext == '' ) { - return wfMsgForContent( 'autosumm-blank' ); - } elseif ( strlen( $oldtext ) > 10 * strlen( $newtext ) && strlen( $newtext ) < 500 ) { - # Removing more than 90% of the article + # NOTE: stub for backwards-compatibility. assumes the given text is wikitext. will break horribly if it isn't. - $truncatedtext = $wgContLang->truncate( - $newtext, - max( 0, 200 - strlen( wfMsgForContent( 'autosumm-replace' ) ) ) ); + wfDeprecated( __METHOD__, '1.WD' ); - return wfMsgForContent( 'autosumm-replace', $truncatedtext ); - } + $handler = ContentHandler::getForModelID( CONTENT_MODEL_WIKITEXT ); + $oldContent = is_null( $oldtext ) ? null : $handler->unserializeContent( $oldtext ); + $newContent = is_null( $newtext ) ? null : $handler->unserializeContent( $newtext ); - # If we reach this point, there's no applicable autosummary for our case, so our - # autosummary is empty. - return ''; + return $handler->getAutosummary( $oldContent, $newContent, $flags ); } /** @@ -2663,91 +2909,7 @@ class WikiPage extends Page { * if no revision occurred */ public function getAutoDeleteReason( &$hasHistory ) { - global $wgContLang; - - // Get the last revision - $rev = $this->getRevision(); - - if ( is_null( $rev ) ) { - return false; - } - - // Get the article's contents - $contents = $rev->getText(); - $blank = false; - - // If the page is blank, use the text from the previous revision, - // which can only be blank if there's a move/import/protect dummy revision involved - if ( $contents == '' ) { - $prev = $rev->getPrevious(); - - if ( $prev ) { - $contents = $prev->getText(); - $blank = true; - } - } - - $dbw = wfGetDB( DB_MASTER ); - - // Find out if there was only one contributor - // Only scan the last 20 revisions - $res = $dbw->select( 'revision', 'rev_user_text', - array( 'rev_page' => $this->getID(), $dbw->bitAnd( 'rev_deleted', Revision::DELETED_USER ) . ' = 0' ), - __METHOD__, - array( 'LIMIT' => 20 ) - ); - - if ( $res === false ) { - // This page has no revisions, which is very weird - return false; - } - - $hasHistory = ( $res->numRows() > 1 ); - $row = $dbw->fetchObject( $res ); - - if ( $row ) { // $row is false if the only contributor is hidden - $onlyAuthor = $row->rev_user_text; - // Try to find a second contributor - foreach ( $res as $row ) { - if ( $row->rev_user_text != $onlyAuthor ) { // Bug 22999 - $onlyAuthor = false; - break; - } - } - } else { - $onlyAuthor = false; - } - - // Generate the summary with a '$1' placeholder - if ( $blank ) { - // The current revision is blank and the one before is also - // blank. It's just not our lucky day - $reason = wfMsgForContent( 'exbeforeblank', '$1' ); - } else { - if ( $onlyAuthor ) { - $reason = wfMsgForContent( 'excontentauthor', '$1', $onlyAuthor ); - } else { - $reason = wfMsgForContent( 'excontent', '$1' ); - } - } - - if ( $reason == '-' ) { - // Allow these UI messages to be blanked out cleanly - return ''; - } - - // Replace newlines with spaces to prevent uglyness - $contents = preg_replace( "/[\n\r]/", ' ', $contents ); - // Calculate the maximum amount of chars to get - // Max content length = max comment length - length of the comment (excl. $1) - $maxLength = 255 - ( strlen( $reason ) - 2 ); - $contents = $wgContLang->truncate( $contents, $maxLength ); - // Remove possible unfinished links - $contents = preg_replace( '/\[\[([^\]]*)\]?$/', '$1', $contents ); - // Now replace the '$1' placeholder - $reason = str_replace( '$1', $contents, $reason ); - - return $reason; + return $this->getContentHandler()->getAutoDeleteReason( $this->getTitle(), $hasHistory ); } /** @@ -2988,6 +3150,27 @@ class WikiPage extends Page { global $wgUser; return $this->isParserCacheUsed( ParserOptions::newFromUser( $wgUser ), $oldid ); } + + /** + * Returns a list of updates to be performed when this page is deleted. The updates should remove any infomration + * about this page from secondary data stores such as links tables. + * + * @param Content|null $content optional Content object for determining the necessary updates + * @return Array an array of DataUpdates objects + */ + public function getDeletionUpdates( Content $content = null ) { + if ( !$content ) { + // load content object, which may be used to determine the necessary updates + // XXX: the content may not be needed to determine the updates, then this would be overhead. + $content = $this->getContent( Revision::RAW ); + } + + $updates = $this->getContentHandler()->getDeletionUpdates( $content, $this->mTitle ); + + wfRunHooks( 'WikiPageDeletionUpdates', array( $this, &$updates ) ); + return $updates; + } + } class PoolWorkArticleView extends PoolCounterWork { @@ -3013,9 +3196,9 @@ class PoolWorkArticleView extends PoolCounterWork { private $parserOptions; /** - * @var string|null + * @var Content|null */ - private $text; + private $content = null; /** * @var ParserOutput|bool @@ -3039,14 +3222,20 @@ class PoolWorkArticleView extends PoolCounterWork { * @param $revid Integer: ID of the revision being parsed * @param $useParserCache Boolean: whether to use the parser cache * @param $parserOptions parserOptions to use for the parse operation - * @param $text String: text to parse or null to load it + * @param $content Content|String: content to parse or null to load it; may also be given as a wikitext string, for BC */ - function __construct( Page $page, ParserOptions $parserOptions, $revid, $useParserCache, $text = null ) { + function __construct( Page $page, ParserOptions $parserOptions, $revid, $useParserCache, $content = null ) { + if ( is_string($content) ) { #BC: old style call + $modelId = $page->getRevision()->getContentModel(); + $format = $page->getRevision()->getContentFormat(); + $content = ContentHandler::makeContent( $content, $page->getTitle(), $modelId, $format ); + } + $this->page = $page; $this->revid = $revid; $this->cacheable = $useParserCache; $this->parserOptions = $parserOptions; - $this->text = $text; + $this->content = $content; $this->cacheKey = ParserCache::singleton()->getKey( $page, $parserOptions ); parent::__construct( 'ArticleView', $this->cacheKey . ':revid:' . $revid ); } @@ -3082,25 +3271,26 @@ class PoolWorkArticleView extends PoolCounterWork { * @return bool */ function doWork() { - global $wgParser, $wgUseFileCache; + global $wgUseFileCache; + + // @todo: several of the methods called on $this->page are not declared in Page, but present in WikiPage and delegated by Article. $isCurrent = $this->revid === $this->page->getLatest(); - if ( $this->text !== null ) { - $text = $this->text; + if ( $this->content !== null ) { + $content = $this->content; } elseif ( $isCurrent ) { - $text = $this->page->getRawText(); + $content = $this->page->getContent( Revision::RAW ); #XXX: why use RAW audience here, and PUBLIC (default) below? } else { $rev = Revision::newFromTitle( $this->page->getTitle(), $this->revid ); if ( $rev === null ) { return false; } - $text = $rev->getText(); + $content = $rev->getContent(); #XXX: why use PUBLIC audience here (default), and RAW above? } $time = - microtime( true ); - $this->parserOutput = $wgParser->parse( $text, $this->page->getTitle(), - $this->parserOptions, true, true, $this->revid ); + $this->parserOutput = $content->getParserOutput( $this->page->getTitle(), $this->revid, $this->parserOptions ); $time += microtime( true ); # Timing hack @@ -3169,3 +3359,4 @@ class PoolWorkArticleView extends PoolCounterWork { return false; } } + diff --git a/includes/actions/EditAction.php b/includes/actions/EditAction.php index 08a33f4c0d..1c97b26c50 100644 --- a/includes/actions/EditAction.php +++ b/includes/actions/EditAction.php @@ -71,4 +71,4 @@ class SubmitAction extends EditAction { parent::show(); } -} +} \ No newline at end of file diff --git a/includes/actions/RawAction.php b/includes/actions/RawAction.php index 174ca3f86c..beabb3b3ff 100644 --- a/includes/actions/RawAction.php +++ b/includes/actions/RawAction.php @@ -148,11 +148,20 @@ class RawAction extends FormlessAction { $request->response()->header( "Last-modified: $lastmod" ); // Public-only due to cache headers - $text = $rev->getText(); + $content = $rev->getContent(); + + if ( !$content instanceof TextContent ) { + wfHttpError( 406, "Not Acceptable", "The requeste page uses the content model `" + . $content->getModel() . "` which is not supported via this interface." ); + die(); + } + $section = $request->getIntOrNull( 'section' ); if ( $section !== null ) { - $text = $wgParser->getSection( $text, $section ); + $content = $content->getSection( $section ); } + + $text = $content->getNativeData(); } } diff --git a/includes/actions/RollbackAction.php b/includes/actions/RollbackAction.php index 0d9a902727..5c85d530b7 100644 --- a/includes/actions/RollbackAction.php +++ b/includes/actions/RollbackAction.php @@ -109,7 +109,8 @@ class RollbackAction extends FormlessAction { $this->getOutput()->returnToMain( false, $this->getTitle() ); if ( !$request->getBool( 'hidediff', false ) && !$this->getUser()->getBoolOption( 'norollbackdiff', false ) ) { - $de = new DifferenceEngine( $this->getContext(), $current->getId(), $newId, false, true ); + $contentHandler = ContentHandler::getForTitle( $this->getTitle() ); + $de = $contentHandler->createDifferenceEngine( $this->getContext(), $current->getId(), $newId, false, true ); $de->showDiff( '', '' ); } } diff --git a/includes/api/ApiComparePages.php b/includes/api/ApiComparePages.php index ed72b29bd7..5106527313 100644 --- a/includes/api/ApiComparePages.php +++ b/includes/api/ApiComparePages.php @@ -35,7 +35,8 @@ class ApiComparePages extends ApiBase { $rev1 = $this->revisionOrTitleOrId( $params['fromrev'], $params['fromtitle'], $params['fromid'] ); $rev2 = $this->revisionOrTitleOrId( $params['torev'], $params['totitle'], $params['toid'] ); - $de = new DifferenceEngine( $this->getContext(), + $contentHandler = ContentHandler::getForModelID( $rev1->getContentModel() ); + $de = $contentHandler->createDifferenceEngine( $this->getContext(), $rev1, $rev2, null, // rcid diff --git a/includes/api/ApiDelete.php b/includes/api/ApiDelete.php index 1927490fe4..ef96d882f0 100644 --- a/includes/api/ApiDelete.php +++ b/includes/api/ApiDelete.php @@ -111,7 +111,7 @@ class ApiDelete extends ApiBase { // Need to pass a throwaway variable because generateReason expects // a reference $hasHistory = false; - $reason = $page->getAutoDeleteReason( $hasHistory ); + $reason = $page->getAutoDeleteReason( $hasHistory ); if ( $reason === false ) { return array( array( 'cannotdelete', $title->getPrefixedText() ) ); } diff --git a/includes/api/ApiEditPage.php b/includes/api/ApiEditPage.php index 9a160070ab..d445d68a4b 100644 --- a/includes/api/ApiEditPage.php +++ b/includes/api/ApiEditPage.php @@ -60,7 +60,7 @@ class ApiEditPage extends ApiBase { if ( $titleObj->isRedirect() ) { $oldTitle = $titleObj; - $titles = Title::newFromRedirectArray( Revision::newFromTitle( $oldTitle )->getText( Revision::FOR_THIS_USER ) ); + $titles = Revision::newFromTitle( $oldTitle )->getContent( Revision::FOR_THIS_USER )->getRedirectChain(); // array_shift( $titles ); $redirValues = array(); @@ -109,21 +109,23 @@ class ApiEditPage extends ApiBase { // We do want getContent()'s behavior for non-existent // MediaWiki: pages, though if ( $articleObj->getID() == 0 && $titleObj->getNamespace() != NS_MEDIAWIKI ) { - $content = ''; + $content = null; + $text = ''; } else { - $content = $articleObj->getContent(); + $content = $articleObj->getContentObject(); + $text = ContentHandler::getContentText( $content ); #FIXME: serialize?! get format from params?... } if ( !is_null( $params['section'] ) ) { // Process the content for section edits - global $wgParser; $section = intval( $params['section'] ); - $content = $wgParser->getSection( $content, $section, false ); - if ( $content === false ) { + $sectionContent = $content->getSection( $section ); + $text = ContentHandler::getContentText( $sectionContent ); #FIXME: serialize?! get format from params?... + if ( $text === false || $text === null ) { $this->dieUsage( "There is no section {$section}.", 'nosuchsection' ); } } - $params['text'] = $params['prependtext'] . $content . $params['appendtext']; + $params['text'] = $params['prependtext'] . $text . $params['appendtext']; $toMD5 = $params['prependtext'] . $params['appendtext']; } @@ -240,7 +242,9 @@ class ApiEditPage extends ApiBase { // TODO: Make them not or check if they still do $wgTitle = $titleObj; - $ep = new EditPage( $articleObj ); + $handler = ContentHandler::getForTitle( $titleObj ); + $ep = $handler->createEditPage( $articleObj ); + $ep->setContextTitle( $titleObj ); $ep->importFormData( $req ); diff --git a/includes/api/ApiFormatNone.php b/includes/api/ApiFormatNone.php new file mode 100644 index 0000000000..31c90e101a --- /dev/null +++ b/includes/api/ApiFormatNone.php @@ -0,0 +1,51 @@ +@gmail.com + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * http://www.gnu.org/copyleft/gpl.html + * + * @file + */ + +/** + * API Serialized PHP output formatter + * @ingroup API + */ +class ApiFormatNone extends ApiFormatBase { + + public function __construct( $main, $format ) { + parent::__construct( $main, $format ); + } + + public function getMimeType() { + return 'text/plain'; + } + + public function execute() { + } + + public function getDescription() { + return 'Output nothing' . parent::getDescription(); + } + + public function getVersion() { + return __CLASS__ . ': $Id$'; + } +} diff --git a/includes/api/ApiMain.php b/includes/api/ApiMain.php index 7414a97f37..fbf2f3e9d4 100644 --- a/includes/api/ApiMain.php +++ b/includes/api/ApiMain.php @@ -104,6 +104,7 @@ class ApiMain extends ApiBase { 'dbgfm' => 'ApiFormatDbg', 'dump' => 'ApiFormatDump', 'dumpfm' => 'ApiFormatDump', + 'none' => 'ApiFormatNone', ); /** diff --git a/includes/api/ApiParse.php b/includes/api/ApiParse.php index dbcd43c12f..72808e0b09 100644 --- a/includes/api/ApiParse.php +++ b/includes/api/ApiParse.php @@ -189,7 +189,7 @@ class ApiParse extends ApiBase { return; } // Not cached (save or load) - $p_result = $wgParser->parse( $params['pst'] ? $this->pstText : $this->text, $titleObj, $popts ); + $p_result = $wgParser->parse( $params['pst'] ? $this->pstText : $this->text, $titleObj, $popts ); #FIXME: use Content object¡ } $result_array = array(); @@ -322,9 +322,9 @@ class ApiParse extends ApiBase { $page = WikiPage::factory( $titleObj ); - if ( $this->section !== false ) { + if ( $this->section !== false ) { #FIXME: get section Content, get parser output, ... $this->text = $this->getSectionText( $page->getRawText(), !is_null( $pageId ) - ? 'page id ' . $pageId : $titleObj->getText() ); + ? 'page id ' . $pageId : $titleObj->getText() ); #FIXME: get section... // Not cached (save or load) return $wgParser->parse( $this->text, $titleObj, $popts ); @@ -336,13 +336,14 @@ class ApiParse extends ApiBase { $this->dieUsage( "There is no revision ID {$page->getLatest()}", 'missingrev' ); } if ( $getWikitext ) { - $this->text = $page->getRawText(); + $this->content = $page->getContent( Revision::RAW ); #FIXME: use $this->content everywhere + $this->text = ContentHandler::getContentText( $this->content ); #FIXME: serialize, get format from params; or use object structure in result? } return $pout; } } - private function getSectionText( $text, $what ) { + private function getSectionText( $text, $what ) { #FIXME: replace with Content::getSection global $wgParser; // Not cached (save or load) $text = $wgParser->getSection( $text, $this->section, false ); diff --git a/includes/api/ApiPurge.php b/includes/api/ApiPurge.php index e27068d156..1a335688e5 100644 --- a/includes/api/ApiPurge.php +++ b/includes/api/ApiPurge.php @@ -86,15 +86,17 @@ class ApiPurge extends ApiBase { if( $forceLinkUpdate ) { if ( !$user->pingLimiter() ) { - global $wgParser, $wgEnableParserCache; + global $wgEnableParserCache; $popts = ParserOptions::newFromContext( $this->getContext() ); $popts->setTidy( true ); - $p_result = $wgParser->parse( $page->getRawText(), $title, $popts, - true, true, $page->getLatest() ); + + # Parse content; note that HTML generation is only needed if we want to cache the result. + $content = $page->getContent( Revision::RAW ); + $p_result = $content->getParserOutput( $title, $page->getLatest(), $popts, $wgEnableParserCache ); # Update the links tables - $updates = $p_result->getSecondaryDataUpdates( $title ); + $updates = $content->getContentHandler()->getSecondaryDataUpdates( $content, $title, null, true, $p_result ); DataUpdate::runUpdates( $updates ); $r['linkupdate'] = ''; diff --git a/includes/api/ApiQueryRevisions.php b/includes/api/ApiQueryRevisions.php index 1a5ad174df..f53f6e8104 100644 --- a/includes/api/ApiQueryRevisions.php +++ b/includes/api/ApiQueryRevisions.php @@ -34,15 +34,15 @@ class ApiQueryRevisions extends ApiQueryBase { private $diffto, $difftotext, $expandTemplates, $generateXML, $section, - $token, $parseContent; + $token, $parseContent, $contentFormat; public function __construct( $query, $moduleName ) { parent::__construct( $query, $moduleName, 'rv' ); } - private $fld_ids = false, $fld_flags = false, $fld_timestamp = false, $fld_size = false, + private $fld_ids = false, $fld_flags = false, $fld_timestamp = false, $fld_size = false, $fld_sha1 = false, $fld_comment = false, $fld_parsedcomment = false, $fld_user = false, $fld_userid = false, - $fld_content = false, $fld_tags = false; + $fld_content = false, $fld_tags = false, $fld_contentmodel = false; private $tokenFunctions; @@ -155,10 +155,21 @@ class ApiQueryRevisions extends ApiQueryBase { $this->fld_parsedcomment = isset ( $prop['parsedcomment'] ); $this->fld_size = isset ( $prop['size'] ); $this->fld_sha1 = isset ( $prop['sha1'] ); + $this->fld_contentmodel = isset ( $prop['contentmodel'] ); $this->fld_userid = isset( $prop['userid'] ); $this->fld_user = isset ( $prop['user'] ); $this->token = $params['token']; + if ( !empty( $params['contentformat'] ) ) { + $n = ContentHandler::getContentFormatID( $params['contentformat'] ); + + if ( is_int( $n ) ) { + $this->contentFormat = $n; + } else { + $this->dieUsage( "Unknown format " . $params['contentformat'], 'badformat' ); + } + } + // Possible indexes used $index = array(); @@ -431,6 +442,10 @@ class ApiQueryRevisions extends ApiQueryBase { } } + if ( $this->fld_contentmodel ) { + $vals['contentmodel'] = $revision->getContentModel(); + } + if ( $this->fld_comment || $this->fld_parsedcomment ) { if ( $revision->isDeleted( Revision::DELETED_COMMENT ) ) { $vals['commenthidden'] = ''; @@ -469,39 +484,82 @@ class ApiQueryRevisions extends ApiQueryBase { } } - $text = null; + $content = null; global $wgParser; if ( $this->fld_content || !is_null( $this->difftotext ) ) { - $text = $revision->getText(); + $content = $revision->getContent(); // Expand templates after getting section content because // template-added sections don't count and Parser::preprocess() // will have less input if ( $this->section !== false ) { - $text = $wgParser->getSection( $text, $this->section, false ); - if ( $text === false ) { + $content = $content->getSection( $this->section, false ); + if ( !$content ) { $this->dieUsage( "There is no section {$this->section} in r" . $revision->getId(), 'nosuchsection' ); } } } if ( $this->fld_content && !$revision->isDeleted( Revision::DELETED_TEXT ) ) { + $text = null; + if ( $this->generateXML ) { - $wgParser->startExternalParse( $title, ParserOptions::newFromContext( $this->getContext() ), OT_PREPROCESS ); - $dom = $wgParser->preprocessToDom( $text ); - if ( is_callable( array( $dom, 'saveXML' ) ) ) { - $xml = $dom->saveXML(); + if ( $content->getModel() === CONTENT_MODEL_WIKITEXT ) { + $t = $content->getNativeData(); # note: don't set $text + + $wgParser->startExternalParse( $title, ParserOptions::newFromContext( $this->getContext() ), OT_PREPROCESS ); + $dom = $wgParser->preprocessToDom( $t ); + if ( is_callable( array( $dom, 'saveXML' ) ) ) { + $xml = $dom->saveXML(); + } else { + $xml = $dom->__toString(); + } + $vals['parsetree'] = $xml; } else { - $xml = $dom->__toString(); + $this->setWarning( "Conversion to XML is supported for wikitext only, " . + $title->getPrefixedDBkey() . + " uses content model #" . $content->getModel() . + " (" . ContentHandler::getContentModelName( $content->getModel() ). ")" ); } - $vals['parsetree'] = $xml; - } + if ( $this->expandTemplates && !$this->parseContent ) { - $text = $wgParser->preprocess( $text, $title, ParserOptions::newFromContext( $this->getContext() ) ); + #XXX: implement template expansion for all content types in ContentHandler? + if ( $content->getModel() === CONTENT_MODEL_WIKITEXT ) { + $text = $content->getNativeData(); + + $text = $wgParser->preprocess( $text, $title, ParserOptions::newFromContext( $this->getContext() ) ); + } else { + $this->setWarning( "Template expansion is supported for wikitext only, " . + $title->getPrefixedDBkey() . + " uses content model #" . $content->getModel() . + " (" . ContentHandler::getContentModelName( $content->getModel() ). ")" ); + + $text = false; + } } if ( $this->parseContent ) { - $text = $wgParser->parse( $text, $title, ParserOptions::newFromContext( $this->getContext() ) )->getText(); + $po = $content->getParserOutput( $title, ParserOptions::newFromContext( $this->getContext() ) ); + $text = $po->getText(); + } + + if ( $text === null ) { + $format = $this->contentFormat ? $this->contentFormat : $content->getDefaultFormat(); + + if ( !$content->isSupportedFormat( $format ) ) { + $model = $content->getModel(); + $formatName = ContentHandler::getContentFormatMimeType( $format ); + $modelName = ContentHandler::getContentModelName( $model ); + $name = $title->getPrefixedDBkey(); + + $this->dieUsage( "The requested format #{$this->contentFormat} ($formatName) is not supported for content model #$model ($modelName) used by $name", 'badformat' ); + } + + $text = $content->serialize( $format ); + $vals['textformat'] = ContentHandler::getContentFormatMimeType( $format ); + } + + if ( $text !== false ) { + ApiResult::setContent( $vals, $text ); } - ApiResult::setContent( $vals, $text ); } elseif ( $this->fld_content ) { $vals['texthidden'] = ''; } @@ -513,11 +571,25 @@ class ApiQueryRevisions extends ApiQueryBase { $vals['diff'] = array(); $context = new DerivativeContext( $this->getContext() ); $context->setTitle( $title ); + $handler = ContentHandler::getForTitle( $title ); + if ( !is_null( $this->difftotext ) ) { - $engine = new DifferenceEngine( $context ); - $engine->setText( $text, $this->difftotext ); + $model = $title->getContentModel(); + + if ( $this->contentFormat && !ContentHandler::getForModelID( $model )->isSupportedFormat( $this->contentFormat ) ) { + $formatName = ContentHandler::getContentFormatMimeType( $this->contentFormat ); + $modelName = ContentHandler::getContentModelName( $model ); + $name = $title->getPrefixedDBkey(); + + $this->dieUsage( "The requested format #{$this->contentFormat} ($formatName) is not supported for content model #$model ($modelName) used by $name", 'badformat' ); + } + + $difftocontent = ContentHandler::makeContent( $this->difftotext, $title, $model, $this->contentFormat ); + + $engine = $handler->createDifferenceEngine( $context ); + $engine->setContent( $content, $difftocontent ); } else { - $engine = new DifferenceEngine( $context, $revision->getID(), $this->diffto ); + $engine = $handler->createDifferenceEngine( $context, $revision->getID(), $this->diffto ); $vals['diff']['from'] = $engine->getOldid(); $vals['diff']['to'] = $engine->getNewid(); } @@ -557,6 +629,7 @@ class ApiQueryRevisions extends ApiQueryBase { 'userid', 'size', 'sha1', + 'contentmodel', 'comment', 'parsedcomment', 'content', @@ -606,6 +679,10 @@ class ApiQueryRevisions extends ApiQueryBase { 'continue' => null, 'diffto' => null, 'difftotext' => null, + 'contentformat' => array( + ApiBase::PARAM_TYPE => array_values( $GLOBALS[ 'wgContentFormatMimeTypes' ] ), + ApiBase::PARAM_DFLT => null + ), ); } @@ -621,6 +698,7 @@ class ApiQueryRevisions extends ApiQueryBase { ' userid - User id of revision creator', ' size - Length (bytes) of the revision', ' sha1 - SHA-1 (base 16) of the revision', + ' contentmodel - Content model id', ' comment - Comment by the user for revision', ' parsedcomment - Parsed comment by the user for the revision', ' content - Text of the revision', @@ -645,6 +723,7 @@ class ApiQueryRevisions extends ApiQueryBase { 'difftotext' => array( 'Text to diff each revision to. Only diffs a limited number of revisions.', "Overrides {$p}diffto. If {$p}section is set, only that section will be diffed against this text" ), 'tag' => 'Only list revisions tagged with this tag', + 'contentformat' => 'Serialization format used for difftotext and expected for output of content', ); } @@ -729,6 +808,7 @@ class ApiQueryRevisions extends ApiQueryBase { array( 'code' => 'badparams', 'info' => 'end and endid cannot be used together' ), array( 'code' => 'badparams', 'info' => 'user and excludeuser cannot be used together' ), array( 'code' => 'nosuchsection', 'info' => 'There is no section section in rID' ), + array( 'code' => 'badformat', 'info' => 'The requested serialization format can not be applied to the page\'s content model' ), ) ); } diff --git a/includes/cache/LinkCache.php b/includes/cache/LinkCache.php index f759c0206d..a48d007e51 100644 --- a/includes/cache/LinkCache.php +++ b/includes/cache/LinkCache.php @@ -74,7 +74,7 @@ class LinkCache { * Get a field of a title object from cache. * If this link is not good, it will return NULL. * @param $title Title - * @param $field String: ('length','redirect','revision') + * @param $field String: ('length','redirect','revision','model') * @return mixed */ public function getGoodLinkFieldObj( $title, $field ) { @@ -102,14 +102,16 @@ class LinkCache { * @param $len Integer: text's length * @param $redir Integer: whether the page is a redirect * @param $revision Integer: latest revision's ID + * @param $model Integer: latest revision's content model ID */ - public function addGoodLinkObj( $id, $title, $len = -1, $redir = null, $revision = false ) { + public function addGoodLinkObj( $id, $title, $len = -1, $redir = null, $revision = false, $model = false ) { $dbkey = $title->getPrefixedDbKey(); $this->mGoodLinks[$dbkey] = intval( $id ); $this->mGoodLinkFields[$dbkey] = array( 'length' => intval( $len ), 'redirect' => intval( $redir ), - 'revision' => intval( $revision ) ); + 'revision' => intval( $revision ), + 'model' => intval( $model ) ); } /** @@ -117,7 +119,7 @@ class LinkCache { * @since 1.19 * @param $title Title * @param $row object which has the fields page_id, page_is_redirect, - * page_latest + * page_latest and page_content_model */ public function addGoodLinkObjFromRow( $title, $row ) { $dbkey = $title->getPrefixedDbKey(); @@ -126,6 +128,7 @@ class LinkCache { 'length' => intval( $row->page_len ), 'redirect' => intval( $row->page_is_redirect ), 'revision' => intval( $row->page_latest ), + 'model' => !empty( $row->page_content_model ) ? intval( $row->page_content_model ) : null, ); } @@ -178,7 +181,8 @@ class LinkCache { * @return Integer */ public function addLinkObj( $nt ) { - global $wgAntiLockFlags; + global $wgAntiLockFlags, $wgContentHandlerUseDB; + wfProfileIn( __METHOD__ ); $key = $nt->getPrefixedDBkey(); @@ -210,8 +214,10 @@ class LinkCache { $options = array(); } - $s = $db->selectRow( 'page', - array( 'page_id', 'page_len', 'page_is_redirect', 'page_latest' ), + $f = array( 'page_id', 'page_len', 'page_is_redirect', 'page_latest' ); + if ( $wgContentHandlerUseDB ) $f[] = 'page_content_model'; + + $s = $db->selectRow( 'page', $f, array( 'page_namespace' => $nt->getNamespace(), 'page_title' => $nt->getDBkey() ), __METHOD__, $options ); # Set fields... diff --git a/includes/cache/MessageCache.php b/includes/cache/MessageCache.php index 24f32d692f..70b53f9b47 100644 --- a/includes/cache/MessageCache.php +++ b/includes/cache/MessageCache.php @@ -768,16 +768,27 @@ class MessageCache { # Try loading it from the database $revision = Revision::newFromTitle( Title::makeTitle( NS_MEDIAWIKI, $title ) ); if ( $revision ) { - $message = $revision->getText(); - if ($message === false) { + $content = $revision->getContent(); + if ( !$content ) { // A possibly temporary loading failure. wfDebugLog( 'MessageCache', __METHOD__ . ": failed to load message page text for {$title} ($code)" ); + $message = null; // no negative caching } else { - $this->mCache[$code][$title] = ' ' . $message; - $this->mMemc->set( $titleKey, ' ' . $message, $this->mExpiry ); + $message = $content->getWikitextForTransclusion(); #XXX: is this the reight way to turn a Content object into a mesage? + + if ( $message === false || $message === null ) { + wfDebugLog( 'MessageCache', __METHOD__ . ": message content doesn't provide wikitext (content model: #" . $content->getContentHandler() . ")" ); + $message = false; // negative caching + } else { + $this->mCache[$code][$title] = ' ' . $message; + $this->mMemc->set( $titleKey, ' ' . $message, $this->mExpiry ); + } } } else { - $message = false; + $message = false; // negative caching + } + + if ( $message === false ) { // negative caching $this->mCache[$code][$title] = '!NONEXISTENT'; $this->mMemc->set( $titleKey, '!NONEXISTENT', $this->mExpiry ); } diff --git a/includes/diff/DairikiDiff.php b/includes/diff/DairikiDiff.php index 72eb5d3c2c..6c706ba811 100644 --- a/includes/diff/DairikiDiff.php +++ b/includes/diff/DairikiDiff.php @@ -15,7 +15,7 @@ * @private * @ingroup DifferenceEngine */ -class _DiffOp { +class _DiffOp { #FIXME: no longer private! var $type; var $orig; var $closing; @@ -44,7 +44,7 @@ class _DiffOp { * @private * @ingroup DifferenceEngine */ -class _DiffOp_Copy extends _DiffOp { +class _DiffOp_Copy extends _DiffOp { #FIXME: no longer private! var $type = 'copy'; function __construct( $orig, $closing = false ) { @@ -68,7 +68,7 @@ class _DiffOp_Copy extends _DiffOp { * @private * @ingroup DifferenceEngine */ -class _DiffOp_Delete extends _DiffOp { +class _DiffOp_Delete extends _DiffOp { #FIXME: no longer private! var $type = 'delete'; function __construct( $lines ) { @@ -89,7 +89,7 @@ class _DiffOp_Delete extends _DiffOp { * @private * @ingroup DifferenceEngine */ -class _DiffOp_Add extends _DiffOp { +class _DiffOp_Add extends _DiffOp { #FIXME: no longer private! var $type = 'add'; function __construct( $lines ) { @@ -110,7 +110,7 @@ class _DiffOp_Add extends _DiffOp { * @private * @ingroup DifferenceEngine */ -class _DiffOp_Change extends _DiffOp { +class _DiffOp_Change extends _DiffOp { #FIXME: no longer private! var $type = 'change'; function __construct( $orig, $closing ) { @@ -150,7 +150,7 @@ class _DiffOp_Change extends _DiffOp { * @private * @ingroup DifferenceEngine */ -class _DiffEngine { +class _DiffEngine { #FIXME: no longer private! const MAX_XREF_LENGTH = 10000; @@ -637,7 +637,7 @@ class _DiffEngine { * @private * @ingroup DifferenceEngine */ -class Diff { +class Diff extends DiffResult { var $edits; /** @@ -647,11 +647,36 @@ class Diff { * @param $from_lines array An array of strings. * (Typically these are lines from a file.) * @param $to_lines array An array of strings. + * @param $eng _DiffEngine|null The diff engine to use. */ - function __construct( $from_lines, $to_lines ) { - $eng = new _DiffEngine; - $this->edits = $eng->diff( $from_lines, $to_lines ); - // $this->_check($from_lines, $to_lines); + function __construct( $from_lines, $to_lines, $eng = null ) { + if ( !$eng ) { + $eng = new _DiffEngine(); + } + + $edits = $eng->diff( $from_lines, $to_lines ); + + parent::__construct( $edits ); + + //$this->_check( $from_lines, $to_lines ); + } +} + +/** + * Class representing the result of 'diffin' two sequences of strings. + * @todo document + * @private + * @ingroup DifferenceEngine + */ +class DiffResult { + + /** + * Constructor. + * + * @param $edits array An array of Edit. + */ + function __construct( $edits ) { + $this->edits = $edits; } /** @@ -726,9 +751,6 @@ class Diff { /** * Get the closing set of lines. * - * This reconstructs the $to_lines parameter passed to the - * constructor. - * * @return array The sequence of strings. */ function closing() { diff --git a/includes/diff/DifferenceEngine.php b/includes/diff/DifferenceEngine.php index e624ec2d5a..224764846f 100644 --- a/includes/diff/DifferenceEngine.php +++ b/includes/diff/DifferenceEngine.php @@ -38,7 +38,7 @@ class DifferenceEngine extends ContextSource { * @private */ var $mOldid, $mNewid; - var $mOldtext, $mNewtext; + var $mOldContent, $mNewContent; protected $mDiffLang; /** @@ -500,19 +500,20 @@ class DifferenceEngine extends ContextSource { $out->setRevisionTimestamp( $this->mNewRev->getTimestamp() ); $out->setArticleFlag( true ); - if ( $this->mNewPage->isCssJsSubpage() || $this->mNewPage->isCssOrJsPage() ) { + if ( $this->mNewPage->isCssJsSubpage() || $this->mNewPage->isCssOrJsPage() ) { #NOTE: only needed for B/C: custom rendering of JS/CSS via hook // Stolen from Article::view --AG 2007-10-11 // Give hooks a chance to customise the output // @TODO: standardize this crap into one function - if ( wfRunHooks( 'ShowRawCssJs', array( $this->mNewtext, $this->mNewPage, $out ) ) ) { - // Wrap the whole lot in a
 and don't parse
-					$m = array();
-					preg_match( '!\.(css|js)$!u', $this->mNewPage->getText(), $m );
-					$out->addHTML( "
\n" );
-					$out->addHTML( htmlspecialchars( $this->mNewtext ) );
-					$out->addHTML( "\n
\n" ); + if ( !Hook::isRegistered( 'ShowRawCssJs' ) + || wfRunHooks( 'ShowRawCssJs', array( ContentHandler::getContentText( $this->mNewContent ), $this->mNewPage, $out ) ) ) { #NOTE: deperecated hook, B/C only + // use the content object's own rendering + $po = $this->mContentObject->getParserOutput(); + $out->addHTML( $po->getText() ); } - } elseif ( !wfRunHooks( 'ArticleViewCustom', array( $this->mNewtext, $this->mNewPage, $out ) ) ) { + } elseif( !wfRunHooks( 'ArticleContentViewCustom', array( $this->mNewContent, $this->mNewPage, $out ) ) ) { + // Handled by extension + } elseif( Hooks::isRegistered( 'ArticleViewCustom' ) + && !wfRunHooks( 'ArticleViewCustom', array( ContentHandler::getContentText( $this->mNewContent ), $this->mNewPage, $out ) ) ) { #NOTE: deperecated hook, B/C only // Handled by extension } else { // Normal page @@ -644,7 +645,7 @@ class DifferenceEngine extends ContextSource { return false; } - $difftext = $this->generateDiffBody( $this->mOldtext, $this->mNewtext ); + $difftext = $this->generateContentDiffBody( $this->mOldContent, $this->mNewContent ); // Save to cache for 7 days if ( !wfRunHooks( 'AbortDiffCache', array( &$this ) ) ) { @@ -681,14 +682,50 @@ class DifferenceEngine extends ContextSource { } } + /** + * Generate a diff, no caching. + * + * Subclasses may override this to provide a + * + * @param $old Content: old content + * @param $new Content: new content + * + * @since 1.WD + */ + function generateContentDiffBody( Content $old, Content $new ) { + #XXX: generate a warning if $old or $new are not instances of TextContent? + #XXX: fail if $old and $new don't have the same content model? or what? + + $otext = $old->serialize(); + $ntext = $new->serialize(); + + #XXX: text should be "already segmented". what does that mean? + return $this->generateTextDiffBody( $otext, $ntext ); + } + /** * Generate a diff, no caching * * @param $otext String: old text, must be already segmented * @param $ntext String: new text, must be already segmented - * @return bool|string + * @deprecated since 1.WD, use generateContentDiffBody() instead! */ function generateDiffBody( $otext, $ntext ) { + wfDeprecated( __METHOD__, "1.WD" ); + + return $this->generateTextDiffBody( $otext, $ntext ); + } + + /** + * Generate a diff, no caching + * + * @todo move this to TextDifferenceEngine, make DifferenceEngine abstract. At some point. + * + * @param $otext String: old text, must be already segmented + * @param $ntext String: new text, must be already segmented + * @return bool|string + */ + function generateTextDiffBody( $otext, $ntext ) { global $wgExternalDiffEngine, $wgContLang; wfProfileIn( __METHOD__ ); @@ -942,10 +979,25 @@ class DifferenceEngine extends ContextSource { /** * Use specified text instead of loading from the database + * @deprecated since 1.WD */ - function setText( $oldText, $newText ) { - $this->mOldtext = $oldText; - $this->mNewtext = $newText; + function setText( $oldText, $newText ) { #FIXME: no longer use this, use setContent()! + wfDeprecated( __METHOD__, "1.WD" ); + + $oldContent = ContentHandler::makeContent( $oldText, $this->getTitle() ); + $newContent = ContentHandler::makeContent( $newText, $this->getTitle() ); + + $this->setContent( $oldContent, $newContent ); + } + + /** + * Use specified text instead of loading from the database + * @since 1.WD + */ + function setContent( Content $oldContent, Content $newContent ) { + $this->mOldContent = $oldContent; + $this->mNewContent = $newContent; + $this->mTextLoaded = 2; $this->mRevisionsLoaded = true; } @@ -1073,14 +1125,14 @@ class DifferenceEngine extends ContextSource { return false; } if ( $this->mOldRev ) { - $this->mOldtext = $this->mOldRev->getText( Revision::FOR_THIS_USER ); - if ( $this->mOldtext === false ) { + $this->mOldContent = $this->mOldRev->getContent( Revision::FOR_THIS_USER ); + if ( $this->mOldContent === false ) { return false; } } if ( $this->mNewRev ) { - $this->mNewtext = $this->mNewRev->getText( Revision::FOR_THIS_USER ); - if ( $this->mNewtext === false ) { + $this->mNewContent = $this->mNewRev->getContent( Revision::FOR_THIS_USER ); + if ( $this->mNewContent === false ) { return false; } } @@ -1101,7 +1153,7 @@ class DifferenceEngine extends ContextSource { if ( !$this->loadRevisionData() ) { return false; } - $this->mNewtext = $this->mNewRev->getText( Revision::FOR_THIS_USER ); + $this->mNewContent = $this->mNewRev->getContent( Revision::FOR_THIS_USER ); return true; } } diff --git a/includes/filerepo/file/LocalFile.php b/includes/filerepo/file/LocalFile.php index 2f55ec1000..22131c61e5 100644 --- a/includes/filerepo/file/LocalFile.php +++ b/includes/filerepo/file/LocalFile.php @@ -1458,9 +1458,9 @@ class LocalFile extends File { global $wgParser; $revision = Revision::newFromTitle( $this->title ); if ( !$revision ) return false; - $text = $revision->getText(); - if ( !$text ) return false; - $pout = $wgParser->parse( $text, $this->title, new ParserOptions() ); + $content = $revision->getContent(); + if ( !$content ) return false; + $pout = $content->getParserOutput( $this->title, null, new ParserOptions() ); return $pout->getText(); } diff --git a/includes/installer/Ibm_db2Updater.php b/includes/installer/Ibm_db2Updater.php index f812ac2774..6b7e88de2c 100644 --- a/includes/installer/Ibm_db2Updater.php +++ b/includes/installer/Ibm_db2Updater.php @@ -87,6 +87,13 @@ class Ibm_db2Updater extends DatabaseUpdater { // 1.20 array( 'addTable', 'config', 'patch-config.sql' ), + + // 1.WD + array( 'addField', 'revision', 'rev_content_format', 'patch-revision-rev_content_format.sql' ), + array( 'addField', 'revision', 'rev_content_model', 'patch-revision-rev_content_model.sql' ), + array( 'addField', 'archive', 'ar_content_format', 'patch-archive-ar_content_format.sql' ), + array( 'addField', 'archive', 'ar_content_model', 'patch-archive-ar_content_model.sql' ), + array( 'addField', 'page', 'page_content_model', 'patch-page-page_content_model.sql' ), ); } } diff --git a/includes/installer/MysqlUpdater.php b/includes/installer/MysqlUpdater.php index e453b0113a..e22f1d14a8 100644 --- a/includes/installer/MysqlUpdater.php +++ b/includes/installer/MysqlUpdater.php @@ -206,6 +206,7 @@ class MysqlUpdater extends DatabaseUpdater { array( 'modifyField', 'user_groups', 'ug_group', 'patch-ug_group-length-increase.sql' ), array( 'addField', 'uploadstash', 'us_chunk_inx', 'patch-uploadstash_chunk.sql' ), array( 'addfield', 'job', 'job_timestamp', 'patch-jobs-add-timestamp.sql' ), + array( 'modifyField', 'user_former_groups', 'ufg_group', 'patch-ufg_group-length-increase.sql' ), // 1.20 @@ -213,6 +214,13 @@ class MysqlUpdater extends DatabaseUpdater { array( 'addIndex', 'revision', 'page_user_timestamp', 'patch-revision-user-page-index.sql' ), array( 'addField', 'ipblocks', 'ipb_parent_block_id', 'patch-ipb-parent-block-id.sql' ), array( 'addIndex', 'ipblocks', 'ipb_parent_block_id', 'patch-ipb-parent-block-id-index.sql' ), + + // 1.WD + array( 'addField', 'revision', 'rev_content_format', 'patch-revision-rev_content_format.sql' ), + array( 'addField', 'revision', 'rev_content_model', 'patch-revision-rev_content_model.sql' ), + array( 'addField', 'archive', 'ar_content_format', 'patch-archive-ar_content_format.sql' ), + array( 'addField', 'archive', 'ar_content_model', 'patch-archive-ar_content_model.sql' ), + array( 'addField', 'page', 'page_content_model', 'patch-page-page_content_model.sql' ), ); } diff --git a/includes/installer/OracleUpdater.php b/includes/installer/OracleUpdater.php index aa3c334252..dc02afb12c 100644 --- a/includes/installer/OracleUpdater.php +++ b/includes/installer/OracleUpdater.php @@ -70,6 +70,13 @@ class OracleUpdater extends DatabaseUpdater { //1.20 array( 'addTable', 'config', 'patch-config.sql' ), + //1.WD + array( 'addField', 'revision', 'rev_content_format', 'patch-revision-rev_content_format.sql' ), + array( 'addField', 'revision', 'rev_content_model', 'patch-revision-rev_content_model.sql' ), + array( 'addField', 'archive', 'ar_content_format', 'patch-archive-ar_content_format.sql' ), + array( 'addField', 'archive', 'ar_content_model', 'patch-archive-ar_content_model.sql' ), + array( 'addField', 'page', 'page_content_model', 'patch-page-page_content_model.sql' ), + // KEEP THIS AT THE BOTTOM!! array( 'doRebuildDuplicateFunction' ), diff --git a/includes/installer/SqliteUpdater.php b/includes/installer/SqliteUpdater.php index 8146274025..f936fb6c78 100644 --- a/includes/installer/SqliteUpdater.php +++ b/includes/installer/SqliteUpdater.php @@ -85,6 +85,7 @@ class SqliteUpdater extends DatabaseUpdater { array( 'modifyField', 'user_groups', 'ug_group', 'patch-ug_group-length-increase.sql' ), array( 'addField', 'uploadstash', 'us_chunk_inx', 'patch-uploadstash_chunk.sql' ), array( 'addfield', 'job', 'job_timestamp', 'patch-jobs-add-timestamp.sql' ), + array( 'modifyField', 'user_former_groups', 'ufg_group', 'patch-ug_group-length-increase.sql' ), // 1.20 @@ -92,6 +93,13 @@ class SqliteUpdater extends DatabaseUpdater { array( 'addIndex', 'revision', 'page_user_timestamp', 'patch-revision-user-page-index.sql' ), array( 'addField', 'ipblocks', 'ipb_parent_block_id', 'patch-ipb-parent-block-id.sql' ), array( 'addIndex', 'ipblocks', 'ipb_parent_block_id', 'patch-ipb-parent-block-id-index.sql' ), + + // 1.WD + array( 'addField', 'revision', 'rev_content_format', 'patch-revision-rev_content_format.sql' ), + array( 'addField', 'revision', 'rev_content_model', 'patch-revision-rev_content_model.sql' ), + array( 'addField', 'archive', 'ar_content_format', 'patch-archive-ar_content_format.sql' ), + array( 'addField', 'archive', 'ar_content_model', 'patch-archive-ar_content_model.sql' ), + array( 'addField', 'page', 'page_content_model', 'patch-page-page_content_model.sql' ), ); } diff --git a/includes/job/DoubleRedirectJob.php b/includes/job/DoubleRedirectJob.php index 4e022586c9..8be6db2189 100644 --- a/includes/job/DoubleRedirectJob.php +++ b/includes/job/DoubleRedirectJob.php @@ -94,16 +94,17 @@ class DoubleRedirectJob extends Job { wfDebug( __METHOD__.": target redirect already deleted, ignoring\n" ); return true; } - $text = $targetRev->getText(); - $currentDest = Title::newFromRedirect( $text ); + $content = $targetRev->getContent(); + $currentDest = $content->getRedirectTarget(); if ( !$currentDest || !$currentDest->equals( $this->redirTitle ) ) { wfDebug( __METHOD__.": Redirect has changed since the job was queued\n" ); return true; } # Check for a suppression tag (used e.g. in periodically archived discussions) + $text = ContentHandler::getContentText( $content ); $mw = MagicWord::get( 'staticredirect' ); - if ( $mw->match( $text ) ) { + if ( $mw->match( $text ) ) { #FIXME: add support for this to ContentHandler/Content wfDebug( __METHOD__.": skipping: suppressed with __STATICREDIRECT__\n" ); return true; } @@ -129,7 +130,7 @@ class DoubleRedirectJob extends Job { # so the regex has to be fairly general $newText = preg_replace( '/ \[ \[ [^\]]* \] \] /x', '[[' . $newTitle->getFullText() . ']]', - $text, 1 ); + $text, 1 ); #FIXME: need a way to do this via ContentHandler! if ( $newText === $text ) { $this->setLastError( 'Text unchanged???' ); diff --git a/includes/job/RefreshLinksJob.php b/includes/job/RefreshLinksJob.php index 7ccf00ddfc..05adec0246 100644 --- a/includes/job/RefreshLinksJob.php +++ b/includes/job/RefreshLinksJob.php @@ -58,11 +58,12 @@ class RefreshLinksJob extends Job { wfProfileIn( __METHOD__.'-parse' ); $options = ParserOptions::newFromUserAndLang( new User, $wgContLang ); - $parserOutput = $wgParser->parse( $revision->getText(), $this->title, $options, true, true, $revision->getId() ); + $content = $revision->getContent(); + $parserOutput = $content->getParserOutput( $this->title, $revision->getId(), $options, false ); wfProfileOut( __METHOD__.'-parse' ); wfProfileIn( __METHOD__.'-update' ); - $updates = $parserOutput->getSecondaryDataUpdates( $this->title, false ); + $updates = $content->getContentHandler()->getSecondaryDataUpdates( $content, $this->title, null, false, $parserOutput ); DataUpdate::runUpdates( $updates ); wfProfileOut( __METHOD__.'-update' ); @@ -132,11 +133,13 @@ class RefreshLinksJob2 extends Job { return false; } wfProfileIn( __METHOD__.'-parse' ); - $parserOutput = $wgParser->parse( $revision->getText(), $title, $options, true, true, $revision->getId() ); + $options = ParserOptions::newFromUserAndLang( new User, $wgContLang ); + $content = $revision->getContent(); + $parserOutput = $content->getParserOutput( $title, $revision->getId(), $options, false ); wfProfileOut( __METHOD__.'-parse' ); wfProfileIn( __METHOD__.'-update' ); - $updates = $parserOutput->getSecondaryDataUpdates( $title, false ); + $updates = $content->getContentHandler()->getSecondaryDataUpdates( $content, $title, null, false, $parserOutput ); DataUpdate::runUpdates( $updates ); wfProfileOut( __METHOD__.'-update' ); diff --git a/includes/parser/Parser.php b/includes/parser/Parser.php index f8814555cf..9527176273 100644 --- a/includes/parser/Parser.php +++ b/includes/parser/Parser.php @@ -3553,7 +3553,13 @@ class Parser { } if ( $rev ) { - $text = $rev->getText(); + $content = $rev->getContent(); + $text = $content->getWikitextForTransclusion(); + + if ( $text === false || $text === null ) { + $text = false; + break; + } } elseif ( $title->getNamespace() == NS_MEDIAWIKI ) { global $wgContLang; $message = wfMessage( $wgContLang->lcfirst( $title->getText() ) )->inContentLanguage(); @@ -3561,16 +3567,17 @@ class Parser { $text = false; break; } + $content = $message->content(); $text = $message->plain(); } else { break; } - if ( $text === false ) { + if ( !$content ) { break; } # Redirect? $finalTitle = $title; - $title = Title::newFromRedirect( $text ); + $title = $content->getRedirectTarget(); } return array( 'text' => $text, diff --git a/includes/parser/ParserOutput.php b/includes/parser/ParserOutput.php index d929f1a532..06870498dd 100644 --- a/includes/parser/ParserOutput.php +++ b/includes/parser/ParserOutput.php @@ -157,7 +157,7 @@ class ParserOutput extends CacheTime { $mTimestamp; # Timestamp of the revision private $mIndexPolicy = ''; # 'index' or 'noindex'? Any other value will result in no change. private $mAccessedOptions = array(); # List of ParserOptions (stored in the keys) - private $mSecondaryDataUpdates = array(); # List of instances of SecondaryDataObject(), used to cause some information extracted from the page in a custom place. + private $mSecondaryDataUpdates = array(); # List of instances of DataUpdate, used to cause some information extracted from the page in a custom place. const EDITSECTION_REGEX = '#<(?:mw:)?editsection page="(.*?)" section="(.*?)"(?:/>|>(.*?)())#'; @@ -480,6 +480,9 @@ class ParserOutput extends CacheTime { * extracted from the page's content, including a LinksUpdate object for all links stored in * this ParserOutput object. * + * @note: Avoid using this method directly, use ContentHandler::getSecondaryDataUpdates() instead! The content + * handler may provide additional update objects. + * * @param $title Title of the page we're updating. If not given, a title object will be created based on $this->getTitleText() * @param $recursive Boolean: queue jobs for recursive updates? * diff --git a/includes/resourceloader/ResourceLoaderWikiModule.php b/includes/resourceloader/ResourceLoaderWikiModule.php index f35e774215..446559a1f5 100644 --- a/includes/resourceloader/ResourceLoaderWikiModule.php +++ b/includes/resourceloader/ResourceLoaderWikiModule.php @@ -82,7 +82,16 @@ abstract class ResourceLoaderWikiModule extends ResourceLoaderModule { if ( !$revision ) { return null; } - return $revision->getRawText(); + + $content = $revision->getContent( Revision::RAW ); + $model = $content->getModel(); + + if ( $model !== CONTENT_MODEL_CSS && $model !== CONTENT_MODEL_JAVASCRIPT ) { + wfDebug( __METHOD__ . "bad content model #$model for JS/CSS page!\n" ); + return null; + } + + return $content->getNativeData(); //NOTE: this is safe, we know it's JS or CSS } /* Methods */ diff --git a/includes/search/SearchEngine.php b/includes/search/SearchEngine.php index 678c530583..ed51f75a6a 100644 --- a/includes/search/SearchEngine.php +++ b/includes/search/SearchEngine.php @@ -801,11 +801,12 @@ class SearchResult { */ protected function initText() { if ( !isset( $this->mText ) ) { - if ( $this->mRevision != null ) - $this->mText = $this->mRevision->getText(); - else // TODO: can we fetch raw wikitext for commons images? + if ( $this->mRevision != null ) { + $content = $this->mRevision->getContent(); + $this->mText = $content->getTextForSearchIndex(); //XXX: maybe we don't even need the text, but the content object? + } else { // TODO: can we fetch raw wikitext for commons images? $this->mText = ''; - + } } } @@ -817,7 +818,7 @@ class SearchResult { global $wgUser, $wgAdvancedSearchHighlighting; $this->initText(); list( $contextlines, $contextchars ) = SearchEngine::userHighlightPrefs( $wgUser ); - $h = new SearchHighlighter(); + $h = new SearchHighlighter(); // TODO: make highliter take a content object. Make ContentHandler a factory for SearchHighliter. if ( $wgAdvancedSearchHighlighting ) return $h->highlightText( $this->mText, $terms, $contextlines, $contextchars ); else diff --git a/includes/specials/SpecialBooksources.php b/includes/specials/SpecialBooksources.php index bc07d586d4..6182f7d146 100644 --- a/includes/specials/SpecialBooksources.php +++ b/includes/specials/SpecialBooksources.php @@ -144,7 +144,7 @@ class SpecialBookSources extends SpecialPage { $title = Title::makeTitleSafe( NS_PROJECT, $page ); # Show list in content language if( is_object( $title ) && $title->exists() ) { $rev = Revision::newFromTitle( $title ); - $this->getOutput()->addWikiText( str_replace( 'MAGICNUMBER', $this->isbn, $rev->getText() ) ); + $this->getOutput()->addWikiText( str_replace( 'MAGICNUMBER', $this->isbn, $rev->getText() ) ); #FIXME: need a way to do this via ContentHandler (or enforce flat text-based content) return true; } diff --git a/includes/specials/SpecialComparePages.php b/includes/specials/SpecialComparePages.php index 9e3c52b981..ecc0b837a2 100644 --- a/includes/specials/SpecialComparePages.php +++ b/includes/specials/SpecialComparePages.php @@ -111,7 +111,8 @@ class SpecialComparePages extends SpecialPage { $rev2 = self::revOrTitle( $data['Revision2'], $data['Page2'] ); if( $rev1 && $rev2 ) { - $de = new DifferenceEngine( $form->getContext(), + $contentHandler = ContentHandler::getForModelID( $rev1->getContentModel() ); + $de = $contentHandler->createDifferenceEngine( $form->getContext(), $rev1, $rev2, null, // rcid diff --git a/includes/specials/SpecialNewpages.php b/includes/specials/SpecialNewpages.php index 1798e8f07a..333aa3c5b9 100644 --- a/includes/specials/SpecialNewpages.php +++ b/includes/specials/SpecialNewpages.php @@ -463,7 +463,7 @@ class SpecialNewpages extends IncludableSpecialPage { $this->msg( 'colon-separator' )->inContentLanguage()->escaped() . htmlspecialchars( FeedItem::stripComment( $revision->getComment() ) ) . "

\n
\n
" . - nl2br( htmlspecialchars( $revision->getText() ) ) . "
"; + nl2br( htmlspecialchars( $revision->getContent()->serialize() ) ) . ""; //TODO: include content model/type in feed item? } return ''; } diff --git a/includes/specials/SpecialUndelete.php b/includes/specials/SpecialUndelete.php index 79c051a215..542ecff5df 100644 --- a/includes/specials/SpecialUndelete.php +++ b/includes/specials/SpecialUndelete.php @@ -112,12 +112,22 @@ class PageArchive { * @return ResultWrapper */ function listRevisions() { + global $wgContentHandlerNoDB; + $dbr = wfGetDB( DB_SLAVE ); + + $fields = array( + 'ar_minor_edit', 'ar_timestamp', 'ar_user', 'ar_user_text', + 'ar_comment', 'ar_len', 'ar_deleted', 'ar_rev_id', 'ar_sha1', + ); + + if ( !$wgContentHandlerNoDB ) { + $fields[] = 'ar_content_format'; + $fields[] = 'ar_content_model'; + } + $res = $dbr->select( 'archive', - array( - 'ar_minor_edit', 'ar_timestamp', 'ar_user', 'ar_user_text', - 'ar_comment', 'ar_len', 'ar_deleted', 'ar_rev_id', 'ar_sha1' - ), + $fields, array( 'ar_namespace' => $this->title->getNamespace(), 'ar_title' => $this->title->getDBkey() ), 'PageArchive::listRevisions', @@ -174,22 +184,32 @@ class PageArchive { * @return Revision */ function getRevision( $timestamp ) { + global $wgContentHandlerNoDB; + $dbr = wfGetDB( DB_SLAVE ); + + $fields = array( + 'ar_rev_id', + 'ar_text', + 'ar_comment', + 'ar_user', + 'ar_user_text', + 'ar_timestamp', + 'ar_minor_edit', + 'ar_flags', + 'ar_text_id', + 'ar_deleted', + 'ar_len', + 'ar_sha1', + ); + + if ( !$wgContentHandlerNoDB ) { + $fields[] = 'ar_content_format'; + $fields[] = 'ar_content_model'; + } + $row = $dbr->selectRow( 'archive', - array( - 'ar_rev_id', - 'ar_text', - 'ar_comment', - 'ar_user', - 'ar_user_text', - 'ar_timestamp', - 'ar_minor_edit', - 'ar_flags', - 'ar_text_id', - 'ar_deleted', - 'ar_len', - 'ar_sha1', - ), + $fields, array( 'ar_namespace' => $this->title->getNamespace(), 'ar_title' => $this->title->getDBkey(), 'ar_timestamp' => $dbr->timestamp( $timestamp ) ), @@ -392,6 +412,8 @@ class PageArchive { * @return Mixed: number of revisions restored or false on failure */ private function undeleteRevisions( $timestamps, $unsuppress = false, $comment = '' ) { + global $wgContentHandlerNoDB; + if ( wfReadOnly() ) { return false; } @@ -445,24 +467,31 @@ class PageArchive { $oldones = "ar_timestamp IN ( {$oldts} )"; } + $fields = array( + 'ar_rev_id', + 'ar_text', + 'ar_comment', + 'ar_user', + 'ar_user_text', + 'ar_timestamp', + 'ar_minor_edit', + 'ar_flags', + 'ar_text_id', + 'ar_deleted', + 'ar_page_id', + 'ar_len', + 'ar_sha1'); + + if ( !$wgContentHandlerNoDB ) { + $fields[] = 'ar_content_format'; + $fields[] = 'ar_content_model'; + } + /** * Select each archived revision... */ $result = $dbw->select( 'archive', - /* fields */ array( - 'ar_rev_id', - 'ar_text', - 'ar_comment', - 'ar_user', - 'ar_user_text', - 'ar_timestamp', - 'ar_minor_edit', - 'ar_flags', - 'ar_text_id', - 'ar_deleted', - 'ar_page_id', - 'ar_len', - 'ar_sha1' ), + $fields, /* WHERE */ array( 'ar_namespace' => $this->title->getNamespace(), 'ar_title' => $this->title->getDBkey(), @@ -843,6 +872,7 @@ class SpecialUndelete extends SpecialPage { if( $this->mPreview ) { // Hide [edit]s + //FIXME: ContentHandler will have to provide some specialized magic to do this $popts = $out->parserOptions(); $popts->setEditSection( false ); $out->parserOptions( $popts ); @@ -854,7 +884,7 @@ class SpecialUndelete extends SpecialPage { 'readonly' => 'readonly', 'cols' => intval( $user->getOption( 'cols' ) ), 'rows' => intval( $user->getOption( 'rows' ) ) ), - $rev->getText( Revision::FOR_THIS_USER, $user ) . "\n" ) . + $rev->getText( Revision::FOR_THIS_USER, $user ) . "\n" ) . //FIXME: ContentHandler will have to provide some specialized magic to do this Xml::openElement( 'div' ) . Xml::openElement( 'form', array( 'method' => 'post', @@ -892,7 +922,8 @@ class SpecialUndelete extends SpecialPage { * @return String: HTML */ function showDiff( $previousRev, $currentRev ) { - $diffEngine = new DifferenceEngine( $this->getContext() ); + $contentHandler = ContentHandler::getForTitle( $this->getTitle() ); + $diffEngine = $contentHandler->createDifferenceEngine( $this->getContext() ); $diffEngine->showDiffStyle(); $this->getOutput()->addHTML( "
" . @@ -909,8 +940,8 @@ class SpecialUndelete extends SpecialPage { $this->diffHeader( $currentRev, 'n' ) . "\n" . "" . - $diffEngine->generateDiffBody( - $previousRev->getText(), $currentRev->getText() ) . + $diffEngine->generateContentDiffBody( + $previousRev->getContent(), $currentRev->getContent() ) . "" . "
\n" ); diff --git a/languages/Language.php b/languages/Language.php index 85b9fabae1..fbe0445cf8 100644 --- a/languages/Language.php +++ b/languages/Language.php @@ -414,6 +414,16 @@ class Language { */ public function setNamespaces( array $namespaces ) { $this->namespaceNames = $namespaces; + $this->mNamespaceIds = null; + } + + /** + * Resets all of the namespace caches. Mainly used for testing + */ + public function resetNamespaces( ) { + $this->namespaceNames = null; + $this->mNamespaceIds = null; + $this->namespaceAliases = null; } /** diff --git a/languages/LanguageConverter.php b/languages/LanguageConverter.php index 18d1dbc0be..e902c64758 100644 --- a/languages/LanguageConverter.php +++ b/languages/LanguageConverter.php @@ -907,7 +907,11 @@ class LanguageConverter { if ( $title && $title->exists() ) { $revision = Revision::newFromTitle( $title ); if ( $revision ) { - $txt = $revision->getRawText(); + if ( $revision->getContentModel() == CONTENT_MODEL_WIKITEXT ) { + $txt = $revision->getContent( Revision::RAW )->getNativeData(); + } + + //@todo: in the future, use a specialized content model, perhaps based on json! } } } diff --git a/languages/classes/LanguageFi.php b/languages/classes/LanguageFi.php index 1865cc5ce1..6a2820d148 100644 --- a/languages/classes/LanguageFi.php +++ b/languages/classes/LanguageFi.php @@ -102,7 +102,7 @@ class LanguageFi extends Language { 'monday' => 'maanantai', 'tuesday' => 'tiistai', 'wednesday' => 'keskiviikko', - 'thursay' => 'torstai', + 'thursday' => 'torstai', 'friday' => 'perjantai', 'saturday' => 'lauantai', 'sunday' => 'sunnuntai', diff --git a/languages/messages/MessagesEn.php b/languages/messages/MessagesEn.php index b7275f89a2..8b40898011 100644 --- a/languages/messages/MessagesEn.php +++ b/languages/messages/MessagesEn.php @@ -889,6 +889,7 @@ $1', 'portal-url' => 'Project:Community portal', 'privacy' => 'Privacy policy', 'privacypage' => 'Project:Privacy policy', +'content-failed-to-parse' => "Failed to parse $2 content for $1 model: $3", 'badaccess' => 'Permission error', 'badaccess-group0' => 'You are not allowed to execute the action you have requested.', @@ -4875,4 +4876,10 @@ Otherwise, you can use the easy form below. Your comment will be added to the pa 'duration-centuries' => '$1 {{PLURAL:$1|century|centuries}}', 'duration-millennia' => '$1 {{PLURAL:$1|millennium|millennia}}', +# Content model IDs for the ContentHandler facility; used by ContentHander::getContentModel() +'content-model-1' => 'wikitext', +'content-model-2' => 'JavaScript', +'content-model-3' => 'CSS', +'content-model-4' => 'plain text', + ); diff --git a/languages/messages/MessagesQqq.php b/languages/messages/MessagesQqq.php index a742f3de8c..79a687a871 100644 --- a/languages/messages/MessagesQqq.php +++ b/languages/messages/MessagesQqq.php @@ -4738,4 +4738,10 @@ $4 is the gender of the target user.', 'api-error-uploaddisabled' => 'API error message that can be used for client side localisation of API errors.', 'api-error-verification-error' => 'The word "extension" refers to the part behind the last dot in a file name, that by convention gives a hint about the kind of data format which a files contents are in.', +# Content model IDs for the ContentHandler facility; used by ContentHander::getContentModel() +'content-model-1' => 'Name for the wikitext content model, used when decribing what type of content a page contains.', +'content-model-2' => 'Name for the JavaScript content model, used when decribing what type of content a page contains.', +'content-model-3' => 'Name for the CSS content model, used when decribing what type of content a page contains.', +'content-model-4' => 'Name for the plain text content model, used when decribing what type of content a page contains.', + ); diff --git a/maintenance/archives/patch-archive-ar_content_format.sql b/maintenance/archives/patch-archive-ar_content_format.sql new file mode 100644 index 0000000000..c62ddfb490 --- /dev/null +++ b/maintenance/archives/patch-archive-ar_content_format.sql @@ -0,0 +1,2 @@ +ALTER TABLE /*$wgDBprefix*/archive + ADD ar_content_format int unsigned DEFAULT NULL; diff --git a/maintenance/archives/patch-archive-ar_content_model.sql b/maintenance/archives/patch-archive-ar_content_model.sql new file mode 100644 index 0000000000..8c18bba9ba --- /dev/null +++ b/maintenance/archives/patch-archive-ar_content_model.sql @@ -0,0 +1,2 @@ +ALTER TABLE /*$wgDBprefix*/archive + ADD ar_content_model int unsigned DEFAULT NULL; diff --git a/maintenance/archives/patch-page-page_content_model.sql b/maintenance/archives/patch-page-page_content_model.sql new file mode 100644 index 0000000000..89df112b81 --- /dev/null +++ b/maintenance/archives/patch-page-page_content_model.sql @@ -0,0 +1,2 @@ +ALTER TABLE /*$wgDBprefix*/page + ADD page_content_model int unsigned DEFAULT NULL; diff --git a/maintenance/archives/patch-revision-rev_content_format.sql b/maintenance/archives/patch-revision-rev_content_format.sql new file mode 100644 index 0000000000..eed0306656 --- /dev/null +++ b/maintenance/archives/patch-revision-rev_content_format.sql @@ -0,0 +1,2 @@ +ALTER TABLE /*$wgDBprefix*/revision + ADD rev_content_format int unsigned DEFAULT NULL; diff --git a/maintenance/archives/patch-revision-rev_content_model.sql b/maintenance/archives/patch-revision-rev_content_model.sql new file mode 100644 index 0000000000..1834b75d20 --- /dev/null +++ b/maintenance/archives/patch-revision-rev_content_model.sql @@ -0,0 +1,2 @@ +ALTER TABLE /*$wgDBprefix*/revision + ADD rev_content_model int unsigned DEFAULT NULL; diff --git a/maintenance/checkBadRedirects.php b/maintenance/checkBadRedirects.php index bac2ff69a0..c2e7871581 100644 --- a/maintenance/checkBadRedirects.php +++ b/maintenance/checkBadRedirects.php @@ -46,7 +46,7 @@ class CheckBadRedirects extends Maintenance { $title = Title::makeTitle( $row->page_namespace, $row->page_title ); $rev = Revision::newFromId( $row->page_latest ); if ( $rev ) { - $target = Title::newFromRedirect( $rev->getText() ); + $target = $rev->getContent()->getRedirectTarget(); if ( !$target ) { $this->output( $title->getPrefixedText() . "\n" ); } diff --git a/maintenance/cleanupSpam.php b/maintenance/cleanupSpam.php index f104899f84..c253cf9548 100644 --- a/maintenance/cleanupSpam.php +++ b/maintenance/cleanupSpam.php @@ -98,6 +98,7 @@ class CleanupSpam extends Maintenance { $rev = Revision::newFromTitle( $title ); $currentRevId = $rev->getId(); + //FIXME: LinkFilter needs to handle Content objects! Or rather, ContentHandler needs to provide the appropriate LinkFilter. while ( $rev && ( $rev->isDeleted( Revision::DELETED_TEXT ) || LinkFilter::matchEntry( $rev->getText() , $domain ) ) ) { $rev = $rev->getPrevious(); } diff --git a/maintenance/cssjanus/cssjanus.py b/maintenance/cssjanus/cssjanus.py old mode 100755 new mode 100644 diff --git a/maintenance/cssjanus/csslex.py b/maintenance/cssjanus/csslex.py old mode 100755 new mode 100644 diff --git a/maintenance/dev/install.sh b/maintenance/dev/install.sh old mode 100755 new mode 100644 diff --git a/maintenance/dev/installmw.sh b/maintenance/dev/installmw.sh old mode 100755 new mode 100644 diff --git a/maintenance/dev/installphp.sh b/maintenance/dev/installphp.sh old mode 100755 new mode 100644 diff --git a/maintenance/dev/start.sh b/maintenance/dev/start.sh old mode 100755 new mode 100644 diff --git a/maintenance/hiphop/make b/maintenance/hiphop/make old mode 100755 new mode 100644 diff --git a/maintenance/hiphop/run-server b/maintenance/hiphop/run-server old mode 100755 new mode 100644 diff --git a/maintenance/populateRevisionLength.php b/maintenance/populateRevisionLength.php index 6626cbc1c5..56e0ca1aaf 100644 --- a/maintenance/populateRevisionLength.php +++ b/maintenance/populateRevisionLength.php @@ -67,16 +67,16 @@ class PopulateRevisionLength extends LoggedUpdateMaintenance { # Go through and update rev_len from these rows. foreach ( $res as $row ) { $rev = new Revision( $row ); - $text = $rev->getRawText(); - if ( !is_string( $text ) ) { + $content = $rev->getContent(); + if ( !$content ) { # This should not happen, but sometimes does (bug 20757) - $this->output( "Text of revision {$row->rev_id} unavailable!\n" ); + $this->output( "Content of revision {$row->rev_id} unavailable!\n" ); $missing++; } else { # Update the row... $db->update( 'revision', - array( 'rev_len' => strlen( $text ) ), + array( 'rev_len' => $content->getSize() ), array( 'rev_id' => $row->rev_id ), __METHOD__ ); $count++; diff --git a/maintenance/populateRevisionSha1.php b/maintenance/populateRevisionSha1.php index 1d8e4c8ba6..af9006b05e 100644 --- a/maintenance/populateRevisionSha1.php +++ b/maintenance/populateRevisionSha1.php @@ -136,14 +136,14 @@ class PopulateRevisionSha1 extends LoggedUpdateMaintenance { $rev = ( $table === 'archive' ) ? Revision::newFromArchiveRow( $row ) : new Revision( $row ); - $text = $rev->getRawText(); + $text = $rev->getSerializedData(); } catch ( MWException $e ) { - $this->output( "Text of revision with {$idCol}={$row->$idCol} unavailable!\n" ); + $this->output( "Data of revision with {$idCol}={$row->$idCol} unavailable!\n" ); return false; // bug 22624? } if ( !is_string( $text ) ) { # This should not happen, but sometimes does (bug 20757) - $this->output( "Text of revision with {$idCol}={$row->$idCol} unavailable!\n" ); + $this->output( "Data of revision with {$idCol}={$row->$idCol} unavailable!\n" ); return false; } else { $db->update( $table, @@ -167,10 +167,10 @@ class PopulateRevisionSha1 extends LoggedUpdateMaintenance { $this->output( "Text of revision with timestamp {$row->ar_timestamp} unavailable!\n" ); return false; // bug 22624? } - $text = $rev->getRawText(); + $text = $rev->getSerializedData(); if ( !is_string( $text ) ) { # This should not happen, but sometimes does (bug 20757) - $this->output( "Text of revision with timestamp {$row->ar_timestamp} unavailable!\n" ); + $this->output( "Data of revision with timestamp {$row->ar_timestamp} unavailable!\n" ); return false; } else { # Archive table as no PK, but (NS,title,time) should be near unique. diff --git a/maintenance/refreshLinks.php b/maintenance/refreshLinks.php index b5aa85f891..21182d76e4 100644 --- a/maintenance/refreshLinks.php +++ b/maintenance/refreshLinks.php @@ -210,18 +210,17 @@ class RefreshLinks extends Maintenance { return; } - $text = $page->getRawText(); - if ( $text === false ) { + $content = $page->getContent( REVISION::RAW ); + if ( null === false ) { return; } $dbw = wfGetDB( DB_MASTER ); $dbw->begin( __METHOD__ ); - $options = ParserOptions::newFromUserAndLang( new User, $wgContLang ); - $parserOutput = $wgParser->parse( $text, $page->getTitle(), $options, true, true, $page->getLatest() ); - $update = new LinksUpdate( $page->getTitle(), $parserOutput, false ); - $update->doUpdate(); + $contentHandler = $content->getContentHandler(); + $updates = $contentHandler->getSecondaryDataUpdates( $content, $page->getTitle() ); + DataUpdate::runUpdates( $updates ); $dbw->commit( __METHOD__ ); } diff --git a/maintenance/storage/make-blobs b/maintenance/storage/make-blobs old mode 100755 new mode 100644 diff --git a/maintenance/storage/testCompression.php b/maintenance/storage/testCompression.php index 9ae2633539..a2124c35d6 100644 --- a/maintenance/storage/testCompression.php +++ b/maintenance/storage/testCompression.php @@ -65,7 +65,7 @@ $uncompressedSize = 0; $t = -microtime( true ); foreach ( $res as $row ) { $revision = new Revision( $row ); - $text = $revision->getText(); + $text = $revision->getSerializedData(); $uncompressedSize += strlen( $text ); $hashes[$row->rev_id] = md5( $text ); $keys[$row->rev_id] = $blob->addItem( $text ); diff --git a/maintenance/tables.sql b/maintenance/tables.sql index 0a5b2fb75a..0aee7415ec 100644 --- a/maintenance/tables.sql +++ b/maintenance/tables.sql @@ -260,7 +260,10 @@ CREATE TABLE /*_*/page ( page_latest int unsigned NOT NULL, -- Uncompressed length in bytes of the page's current source text. - page_len int unsigned NOT NULL + page_len int unsigned NOT NULL, + + -- content model, see CONTENT_MODEL_XXX constants + page_content_model int unsigned default NULL ) /*$wgDBTableOptions*/; CREATE UNIQUE INDEX /*i*/name_title ON /*_*/page (page_namespace,page_title); @@ -316,7 +319,13 @@ CREATE TABLE /*_*/revision ( rev_parent_id int unsigned default NULL, -- SHA-1 text content hash in base-36 - rev_sha1 varbinary(32) NOT NULL default '' + rev_sha1 varbinary(32) NOT NULL default '', + + -- content model, see CONTENT_MODEL_XXX constants + rev_content_model int unsigned default NULL, + + -- content format, see CONTENT_FORMAT_XXX constants + rev_content_format int unsigned default NULL ) /*$wgDBTableOptions*/ MAX_ROWS=10000000 AVG_ROW_LENGTH=1024; -- In case tables are created as MyISAM, use row hints for MySQL <5.0 to avoid 4GB limit @@ -427,7 +436,14 @@ CREATE TABLE /*_*/archive ( ar_parent_id int unsigned default NULL, -- SHA-1 text content hash in base-36 - ar_sha1 varbinary(32) NOT NULL default '' + ar_sha1 varbinary(32) NOT NULL default '', + + -- content model, see CONTENT_MODEL_XXX constants + ar_content_model int unsigned default NULL, + + -- content format, see CONTENT_FORMAT_XXX constants + ar_content_format int unsigned default NULL + ) /*$wgDBTableOptions*/; CREATE INDEX /*i*/name_title_timestamp ON /*_*/archive (ar_namespace,ar_title,ar_timestamp); diff --git a/tests/phpunit/MediaWikiPHPUnitCommand.php b/tests/phpunit/MediaWikiPHPUnitCommand.php index ea385ad9b2..18475cac8c 100644 --- a/tests/phpunit/MediaWikiPHPUnitCommand.php +++ b/tests/phpunit/MediaWikiPHPUnitCommand.php @@ -53,6 +53,19 @@ class MediaWikiPHPUnitCommand extends PHPUnit_TextUI_Command { } } + protected function handleCustomTestSuite() { + if ( empty( $this->arguments['printer'] ) ) { + $this->arguments['printer'] = new PHPUnit_TextUI_ResultPrinter( + null, + isset($this->arguments['verbose']) ? $this->arguments['verbose'] : false, + isset($this->arguments['colors']) ? $this->arguments['colors'] : true, + isset($this->arguments['debug']) ? $this->arguments['debug'] : false + ); + } + + parent::handleCustomTestSuite(); + } + public function showHelp() { parent::showHelp(); @@ -74,3 +87,30 @@ EOT; } } + +class MediaWikiPHPUnitResultPrinter extends PHPUnit_TextUI_ResultPrinter { + /** + * Overrides original method to ignore incomplete tests except in verbose mode. + * + * @param PHPUnit_Framework_TestResult $result + */ + protected function printIncompletes(PHPUnit_Framework_TestResult $result) + { + if ( $this->verbose ) { + parent::printIncompletes( $result ); + } + } + + /** + * Overrides original method to ignore skipped tests except in verbose mode. + * + * @param PHPUnit_Framework_TestResult $result + */ + protected function printSkipped(PHPUnit_Framework_TestResult $result) + { + if ( $this->verbose ) { + parent::printSkipped( $result ); + } + } + +} diff --git a/tests/phpunit/includes/ContentHandlerTest.php b/tests/phpunit/includes/ContentHandlerTest.php new file mode 100644 index 0000000000..155d4f7e9b --- /dev/null +++ b/tests/phpunit/includes/ContentHandlerTest.php @@ -0,0 +1,511 @@ +resetNamespaces(); # reset namespace cache + } + + public function tearDown() { + global $wgExtraNamespaces, $wgNamespaceContentModels, $wgContentHandlers, $wgContLang; + + unset( $wgExtraNamespaces[ 12312 ] ); + unset( $wgExtraNamespaces[ 12313 ] ); + + unset( $wgNamespaceContentModels[ 12312 ] ); + unset( $wgContentHandlers[ 999999 ] ); + + MWNamespace::getCanonicalNamespaces( true ); # reset namespace cache + $wgContLang->resetNamespaces(); # reset namespace cache + } + + public function dataGetDefaultModelFor() { + return array( + array( 'Foo', CONTENT_MODEL_WIKITEXT ), + array( 'Foo.js', CONTENT_MODEL_WIKITEXT ), + array( 'Foo/bar.js', CONTENT_MODEL_WIKITEXT ), + array( 'User:Foo', CONTENT_MODEL_WIKITEXT ), + array( 'User:Foo.js', CONTENT_MODEL_WIKITEXT ), + array( 'User:Foo/bar.js', CONTENT_MODEL_JAVASCRIPT ), + array( 'User:Foo/bar.css', CONTENT_MODEL_CSS ), + array( 'User talk:Foo/bar.css', CONTENT_MODEL_WIKITEXT ), + array( 'User:Foo/bar.js.xxx', CONTENT_MODEL_WIKITEXT ), + array( 'User:Foo/bar.xxx', CONTENT_MODEL_WIKITEXT ), + array( 'MediaWiki:Foo.js', CONTENT_MODEL_JAVASCRIPT ), + array( 'MediaWiki:Foo.css', CONTENT_MODEL_CSS ), + array( 'MediaWiki:Foo.JS', CONTENT_MODEL_WIKITEXT ), + array( 'MediaWiki:Foo.CSS', CONTENT_MODEL_WIKITEXT ), + array( 'MediaWiki:Foo.css.xxx', CONTENT_MODEL_WIKITEXT ), + ); + } + + /** + * @dataProvider dataGetDefaultModelFor + */ + public function testGetDefaultModelFor( $title, $expectedModelId ) { + $title = Title::newFromText( $title ); + $this->assertEquals( $expectedModelId, ContentHandler::getDefaultModelFor( $title ) ); + } + /** + * @dataProvider dataGetDefaultModelFor + */ + public function testGetForTitle( $title, $expectedContentModel ) { + $title = Title::newFromText( $title ); + $handler = ContentHandler::getForTitle( $title ); + $this->assertEquals( $expectedContentModel, $handler->getModelID() ); + } + + public function dataGetContentFormatMimeType( ) { + return array( + array( 0, null ), + array( null, null ), + array( 99887766, null ), + + array( CONTENT_FORMAT_WIKITEXT, 'text/x-wiki' ), + array( CONTENT_FORMAT_JAVASCRIPT, 'text/javascript' ), + array( CONTENT_FORMAT_CSS, 'text/css' ), + array( CONTENT_FORMAT_JSON, 'application/json' ), + array( CONTENT_FORMAT_XML, 'application/xml' ), + array( CONTENT_FORMAT_SERIALIZED, 'application/vnd.php.serialized' ), + ); + } + + /** + * @dataProvider dataGetContentFormatMimeType + */ + public function testGetContentFormatMimeType( $id, $expectedMime ) { + $mime = ContentHandler::getContentFormatMimeType( $id ); + + $this->assertEquals( $expectedMime, $mime ); + } + + public function dataGetContentFormatID( ) { + return array( + array( '', null ), + array( 'foo', null ), + array( null, null ), + + array( 'text/x-wiki', CONTENT_FORMAT_WIKITEXT ), + array( 'text/javascript', CONTENT_FORMAT_JAVASCRIPT ), + array( 'text/css', CONTENT_FORMAT_CSS ), + array( 'application/json', CONTENT_FORMAT_JSON ), + array( 'application/xml', CONTENT_FORMAT_XML ), + array( 'application/vnd.php.serialized', CONTENT_FORMAT_SERIALIZED ), + ); + } + + /** + * @dataProvider dataGetContentFormatID + */ + public function testGetContentFormatID( $mime, $expectedId ) { + $id = ContentHandler::getContentFormatID( $mime ); + + $this->assertEquals( $expectedId, $id ); + } + + public function dataGetContentModelName() { + return array( + array( 0, null ), + array( null, null ), + array( 99887766, null ), + + array( CONTENT_MODEL_JAVASCRIPT, '/javascript/i' ), //XXX: depends on content language + ); + } + + /** + * @dataProvider dataGetContentModelName + */ + public function testGetContentModelName( $id, $expected ) { + $name = ContentHandler::getContentModelName( $id ); + + if ( $expected === null ) { + $this->assertNull( $name, "content model name for #$id was expected to be null" ); + } else { + $this->assertNotNull( $name, "no name found for content model #$id" ); + $this->assertTrue( preg_match( $expected, $name ) > 0 , "content model name for #$id did not match pattern $expected" ); + } + } + + public function testGetContentText_Null( ) { + global $wgContentHandlerTextFallback; + + $content = null; + + $wgContentHandlerTextFallback = 'fail'; + $text = ContentHandler::getContentText( $content ); + $this->assertEquals( '', $text ); + + $wgContentHandlerTextFallback = 'serialize'; + $text = ContentHandler::getContentText( $content ); + $this->assertEquals( '', $text ); + + $wgContentHandlerTextFallback = 'ignore'; + $text = ContentHandler::getContentText( $content ); + $this->assertEquals( '', $text ); + } + + public function testGetContentText_TextContent( ) { + global $wgContentHandlerTextFallback; + + $content = new WikitextContent( "hello world" ); + + $wgContentHandlerTextFallback = 'fail'; + $text = ContentHandler::getContentText( $content ); + $this->assertEquals( $content->getNativeData(), $text ); + + $wgContentHandlerTextFallback = 'serialize'; + $text = ContentHandler::getContentText( $content ); + $this->assertEquals( $content->serialize(), $text ); + + $wgContentHandlerTextFallback = 'ignore'; + $text = ContentHandler::getContentText( $content ); + $this->assertEquals( $content->getNativeData(), $text ); + } + + public function testGetContentText_NonTextContent( ) { + global $wgContentHandlerTextFallback; + + $content = new DummyContentForTesting( "hello world" ); + + $wgContentHandlerTextFallback = 'fail'; + + try { + $text = ContentHandler::getContentText( $content ); + + $this->fail( "ContentHandler::getContentText should have thrown an exception for non-text Content object" ); + } catch (MWException $ex) { + // as expected + } + + $wgContentHandlerTextFallback = 'serialize'; + $text = ContentHandler::getContentText( $content ); + $this->assertEquals( $content->serialize(), $text ); + + $wgContentHandlerTextFallback = 'ignore'; + $text = ContentHandler::getContentText( $content ); + $this->assertNull( $text ); + } + + #public static function makeContent( $text, Title $title, $modelId = null, $format = null ) + + public function dataMakeContent() { + return array( + array( 'hallo', 'Test', null, null, CONTENT_MODEL_WIKITEXT, 'hallo', false ), + array( 'hallo', 'MediaWiki:Test.js', null, null, CONTENT_MODEL_JAVASCRIPT, 'hallo', false ), + array( serialize('hallo'), 'Dummy:Test', null, null, 999999, 'hallo', false ), + + array( 'hallo', 'Test', null, CONTENT_FORMAT_WIKITEXT, CONTENT_MODEL_WIKITEXT, 'hallo', false ), + array( 'hallo', 'MediaWiki:Test.js', null, CONTENT_FORMAT_JAVASCRIPT, CONTENT_MODEL_JAVASCRIPT, 'hallo', false ), + array( serialize('hallo'), 'Dummy:Test', null, 999999, 999999, 'hallo', false ), + + array( 'hallo', 'Test', CONTENT_MODEL_CSS, null, CONTENT_MODEL_CSS, 'hallo', false ), + array( 'hallo', 'MediaWiki:Test.js', CONTENT_MODEL_CSS, null, CONTENT_MODEL_CSS, 'hallo', false ), + array( serialize('hallo'), 'Dummy:Test', CONTENT_MODEL_CSS, null, CONTENT_MODEL_CSS, serialize('hallo'), false ), + + array( 'hallo', 'Test', CONTENT_MODEL_WIKITEXT, 999999, null, null, true ), + array( 'hallo', 'MediaWiki:Test.js', CONTENT_MODEL_CSS, 999999, null, null, true ), + array( 'hallo', 'Dummy:Test', CONTENT_MODEL_JAVASCRIPT, 999999, null, null, true ), + ); + } + + /** + * @dataProvider dataMakeContent + */ + public function testMakeContent( $data, $title, $modelId, $format, $expectedModelId, $expectedNativeData, $shouldFail ) { + global $wgExtraNamespaces, $wgNamespaceContentModels, $wgContentHandlers; + + $title = Title::newFromText( $title ); + + try { + $content = ContentHandler::makeContent( $data, $title, $modelId, $format ); + + if ( $shouldFail ) $this->fail( "ContentHandler::makeContent should have failed!" ); + + $this->assertEquals( $expectedModelId, $content->getModel(), 'bad model id' ); + $this->assertEquals( $expectedNativeData, $content->getNativeData(), 'bads native data' ); + } catch ( MWException $ex ) { + if ( !$shouldFail ) $this->fail( "ContentHandler::makeContent failed unexpectedly: " . $ex->getMessage() ); + else $this->assertTrue( true ); // dummy, so we don't get the "test did not perform any assertions" message. + } + + } + + public function dataGetParserOutput() { + return array( + array("ContentHandlerTest_testGetParserOutput", "hello ''world''\n", "

hello world\n

"), + // @todo: more...? + ); + } + + /** + * @dataProvider dataGetParserOutput + */ + public function testGetParserOutput( $title, $text, $expectedHtml ) { + $title = Title::newFromText( $title ); + $handler = ContentHandler::getForModelID( $title->getContentModel() ); + $content = ContentHandler::makeContent( $text, $title ); + + $po = $handler->getParserOutput( $content, $title ); + + $this->assertEquals( $expectedHtml, $po->getText() ); + // @todo: assert more properties + } + + public function dataGetSecondaryDataUpdates() { + return array( + array("ContentHandlerTest_testGetSecondaryDataUpdates_1", "hello ''world''\n", + array( 'LinksUpdate' => array( 'mRecursive' => true, + 'mLinks' => array() ) ) + ), + array("ContentHandlerTest_testGetSecondaryDataUpdates_2", "hello [[world test 21344]]\n", + array( 'LinksUpdate' => array( 'mRecursive' => true, + 'mLinks' => array( array( 'World_test_21344' => 0 ) ) ) ) + ), + // @todo: more...? + ); + } + + /** + * @dataProvider dataGetSecondaryDataUpdates + */ + public function testGetSecondaryDataUpdates( $title, $text, $expectedStuff ) { + $title = Title::newFromText( $title ); + $title->resetArticleID( 2342 ); //dummy id. fine as long as we don't try to execute the updates! + + $handler = ContentHandler::getForModelID( $title->getContentModel() ); + $content = ContentHandler::makeContent( $text, $title ); + + $updates = $handler->getSecondaryDataUpdates( $content, $title ); + + // make updates accessible by class name + foreach ( $updates as $update ) { + $class = get_class( $update ); + $updates[ $class ] = $update; + } + + foreach ( $expectedStuff as $class => $fieldValues ) { + $this->assertArrayHasKey( $class, $updates, "missing an update of type $class" ); + + $update = $updates[ $class ]; + + foreach ( $fieldValues as $field => $value ) { + $v = $update->$field; #if the field doesn't exist, just crash and burn + $this->assertEquals( $value, $v, "unexpected value for field $field in instance of $class" ); + } + } + } + + public function dataGetDeletionUpdates() { + return array( + array("ContentHandlerTest_testGetSecondaryDataUpdates_1", "hello ''world''\n", + array( 'LinksDeletionUpdate' => array( ) ) + ), + array("ContentHandlerTest_testGetSecondaryDataUpdates_2", "hello [[world test 21344]]\n", + array( 'LinksDeletionUpdate' => array( ) ) + ), + // @todo: more...? + ); + } + + /** + * @dataProvider dataGetDeletionUpdates + */ + public function testDeletionUpdates( $title, $text, $expectedStuff ) { + $title = Title::newFromText( $title ); + $title->resetArticleID( 2342 ); //dummy id. fine as long as we don't try to execute the updates! + + $handler = ContentHandler::getForModelID( $title->getContentModel() ); + $content = ContentHandler::makeContent( $text, $title ); + + $updates = $handler->getDeletionUpdates( $content, $title ); + + // make updates accessible by class name + foreach ( $updates as $update ) { + $class = get_class( $update ); + $updates[ $class ] = $update; + } + + foreach ( $expectedStuff as $class => $fieldValues ) { + $this->assertArrayHasKey( $class, $updates, "missing an update of type $class" ); + + $update = $updates[ $class ]; + + foreach ( $fieldValues as $field => $value ) { + $v = $update->$field; #if the field doesn't exist, just crash and burn + $this->assertEquals( $value, $v, "unexpected value for field $field in instance of $class" ); + } + } + } +} + +class DummyContentHandlerForTesting extends ContentHandler { + + public function __construct( $dataModel ) { + parent::__construct( $dataModel, array( 999999 ) ); + } + + /** + * Serializes Content object of the type supported by this ContentHandler. + * + * @param Content $content the Content object to serialize + * @param null $format the desired serialization format + * @return String serialized form of the content + */ + public function serializeContent( Content $content, $format = null ) + { + return $content->serialize(); + } + + /** + * Unserializes a Content object of the type supported by this ContentHandler. + * + * @param $blob String serialized form of the content + * @param null $format the format used for serialization + * @return Content the Content object created by deserializing $blob + */ + public function unserializeContent( $blob, $format = null ) + { + $d = unserialize( $blob ); + return new DummyContentForTesting( $d ); + } + + /** + * Creates an empty Content object of the type supported by this ContentHandler. + * + */ + public function makeEmptyContent() + { + return new DummyContentForTesting( '' ); + } + + /** + * @param Content $content + * @param Title $title + * @param null $revId + * @param null|ParserOptions $options + * @param Boolean $generateHtml whether to generate Html (default: true). If false, + * the result of calling getText() on the ParserOutput object returned by + * this method is undefined. + * + * @return ParserOutput + */ + public function getParserOutput( Content $content, Title $title, $revId = null, ParserOptions $options = NULL, $generateHtml = true ) + { + return new ParserOutput( $content->getNativeData() ); + } +} + +class DummyContentForTesting extends AbstractContent { + + public function __construct( $data ) { + parent::__construct( 999999 ); + + $this->data = $data; + } + + public function serialize( $format = null ) { + return serialize( $this->data ); + } + + /** + * @return String a string representing the content in a way useful for building a full text search index. + * If no useful representation exists, this method returns an empty string. + */ + public function getTextForSearchIndex() + { + return ''; + } + + /** + * @return String the wikitext to include when another page includes this content, or false if the content is not + * includable in a wikitext page. + */ + public function getWikitextForTransclusion() + { + return false; + } + + /** + * Returns a textual representation of the content suitable for use in edit summaries and log messages. + * + * @param int $maxlength maximum length of the summary text + * @return String the summary text + */ + public function getTextForSummary( $maxlength = 250 ) + { + return ''; + } + + /** + * Returns native represenation of the data. Interpretation depends on the data model used, + * as given by getDataModel(). + * + * @return mixed the native representation of the content. Could be a string, a nested array + * structure, an object, a binary blob... anything, really. + */ + public function getNativeData() + { + return $this->data; + } + + /** + * returns the content's nominal size in bogo-bytes. + * + * @return int + */ + public function getSize() + { + return strlen( $this->data ); + } + + /** + * Return a copy of this Content object. The following must be true for the object returned + * if $copy = $original->copy() + * + * * get_class($original) === get_class($copy) + * * $original->getModel() === $copy->getModel() + * * $original->equals( $copy ) + * + * If and only if the Content object is imutable, the copy() method can and should + * return $this. That is, $copy === $original may be true, but only for imutable content + * objects. + * + * @return Content. A copy of this object + */ + public function copy() + { + return $this; + } + + /** + * Returns true if this content is countable as a "real" wiki page, provided + * that it's also in a countable location (e.g. a current revision in the main namespace). + * + * @param $hasLinks Bool: if it is known whether this content contains links, provide this information here, + * to avoid redundant parsing to find out. + * @return boolean + */ + public function isCountable( $hasLinks = null ) + { + return false; + } +} + diff --git a/tests/phpunit/includes/CssContentTest.php b/tests/phpunit/includes/CssContentTest.php new file mode 100644 index 0000000000..66b6e7f633 --- /dev/null +++ b/tests/phpunit/includes/CssContentTest.php @@ -0,0 +1,44 @@ +\n", "
\nhello <world>\n\n
\n"), + // @todo: more...? + ); + } + + + # ================================================================================================================= + + public function testGetModel() { + $content = $this->newContent( "hello world." ); + + $this->assertEquals( CONTENT_MODEL_CSS, $content->getModel() ); + } + + public function testGetContentHandler() { + $content = $this->newContent( "hello world." ); + + $this->assertEquals( CONTENT_MODEL_CSS, $content->getContentHandler()->getModelID() ); + } + + public function dataEquals( ) { + return array( + array( new CssContent( "hallo" ), null, false ), + array( new CssContent( "hallo" ), new CssContent( "hallo" ), true ), + array( new CssContent( "hallo" ), new WikitextContent( "hallo" ), false ), + array( new CssContent( "hallo" ), new CssContent( "HALLO" ), false ), + ); + } + +} diff --git a/tests/phpunit/includes/JavascriptContentTest.php b/tests/phpunit/includes/JavascriptContentTest.php new file mode 100644 index 0000000000..c402458b8c --- /dev/null +++ b/tests/phpunit/includes/JavascriptContentTest.php @@ -0,0 +1,239 @@ +\n", "
\nhello <world>\n\n
\n"), + // @todo: more...? + ); + } + + public function dataGetSection() { + return array( + array( WikitextContentTest::$sections, + "0", + null + ), + array( WikitextContentTest::$sections, + "2", + null + ), + array( WikitextContentTest::$sections, + "8", + null + ), + ); + } + + public function dataReplaceSection() { + return array( + array( WikitextContentTest::$sections, + "0", + "No more", + null, + null + ), + array( WikitextContentTest::$sections, + "", + "No more", + null, + null + ), + array( WikitextContentTest::$sections, + "2", + "== TEST ==\nmore fun", + null, + null + ), + array( WikitextContentTest::$sections, + "8", + "No more", + null, + null + ), + array( WikitextContentTest::$sections, + "new", + "No more", + "New", + null + ), + ); + } + + public function testAddSectionHeader( ) { + $content = $this->newContent( 'hello world' ); + $c = $content->addSectionHeader( 'test' ); + + $this->assertTrue( $content->equals( $c ) ); + } + + // XXX: currently, preSaveTransform is applied to scripts. this may change or become optional. + /* + public function dataPreSaveTransform() { + return array( + array( 'hello this is ~~~', + "hello this is ~~~", + ), + array( 'hello \'\'this\'\' is ~~~', + 'hello \'\'this\'\' is ~~~', + ), + ); + } + */ + + public function dataPreloadTransform() { + return array( + array( 'hello this is ~~~', + "hello this is ~~~", + ), + array( 'hello \'\'this\'\' is foobar', + 'hello \'\'this\'\' is foobar', + ), + ); + } + + public function dataGetRedirectTarget() { + return array( + array( '#REDIRECT [[Test]]', + null, + ), + array( '#REDIRECT Test', + null, + ), + array( '* #REDIRECT [[Test]]', + null, + ), + ); + } + + /** + * @todo: test needs database! + */ + /* + public function getRedirectChain() { + $text = $this->getNativeData(); + return Title::newFromRedirectArray( $text ); + } + */ + + /** + * @todo: test needs database! + */ + /* + public function getUltimateRedirectTarget() { + $text = $this->getNativeData(); + return Title::newFromRedirectRecurse( $text ); + } + */ + + + public function dataIsCountable() { + return array( + array( '', + null, + 'any', + true + ), + array( 'Foo', + null, + 'any', + true + ), + array( 'Foo', + null, + 'comma', + false + ), + array( 'Foo, bar', + null, + 'comma', + false + ), + array( 'Foo', + null, + 'link', + false + ), + array( 'Foo [[bar]]', + null, + 'link', + false + ), + array( 'Foo', + true, + 'link', + false + ), + array( 'Foo [[bar]]', + false, + 'link', + false + ), + array( '#REDIRECT [[bar]]', + true, + 'any', + true + ), + array( '#REDIRECT [[bar]]', + true, + 'comma', + false + ), + array( '#REDIRECT [[bar]]', + true, + 'link', + false + ), + ); + } + + public function dataGetTextForSummary() { + return array( + array( "hello\nworld.", + 16, + 'hello world.', + ), + array( 'hello world.', + 8, + 'hello...', + ), + array( '[[hello world]].', + 8, + '[[hel...', + ), + ); + } + + # ================================================================================================================= + + public function testGetModel() { + $content = $this->newContent( "hello world." ); + + $this->assertEquals( CONTENT_MODEL_JAVASCRIPT, $content->getModel() ); + } + + public function testGetContentHandler() { + $content = $this->newContent( "hello world." ); + + $this->assertEquals( CONTENT_MODEL_JAVASCRIPT, $content->getContentHandler()->getModelID() ); + } + + public function dataEquals( ) { + return array( + array( new JavascriptContent( "hallo" ), null, false ), + array( new JavascriptContent( "hallo" ), new JavascriptContent( "hallo" ), true ), + array( new JavascriptContent( "hallo" ), new CssContent( "hallo" ), false ), + array( new JavascriptContent( "hallo" ), new JavascriptContent( "HALLO" ), false ), + ); + } + +} diff --git a/tests/phpunit/includes/LinksUpdateTest.php b/tests/phpunit/includes/LinksUpdateTest.php index 494620015a..a50a2235e2 100644 --- a/tests/phpunit/includes/LinksUpdateTest.php +++ b/tests/phpunit/includes/LinksUpdateTest.php @@ -146,7 +146,9 @@ class LinksUpdateTest extends MediaWikiTestCase { protected function assertLinksUpdate( Title $title, ParserOutput $parserOutput, $table, $fields, $condition, Array $expectedRows ) { $update = new LinksUpdate( $title, $parserOutput ); + $update->beginTransaction(); $update->doUpdate(); + $update->commitTransaction(); $this->assertSelect( $table, $fields, $condition, $expectedRows ); } diff --git a/tests/phpunit/includes/RevisionStorageTest.php b/tests/phpunit/includes/RevisionStorageTest.php index 20199b203b..8b77746183 100644 --- a/tests/phpunit/includes/RevisionStorageTest.php +++ b/tests/phpunit/includes/RevisionStorageTest.php @@ -3,6 +3,7 @@ /** * Test class for Revision storage. * + * @group ContentHandler * @group Database * ^--- important, causes temporary tables to be used instead of the real database */ @@ -32,11 +33,35 @@ class RevisionStorageTest extends MediaWikiTestCase { } public function setUp() { + global $wgExtraNamespaces, $wgNamespaceContentModels, $wgContentHandlers, $wgContLang; + + $wgExtraNamespaces[ 12312 ] = 'Dummy'; + $wgExtraNamespaces[ 12313 ] = 'Dummy_talk'; + + $wgNamespaceContentModels[ 12312 ] = 'DUMMY'; + $wgContentHandlers[ 'DUMMY' ] = 'DummyContentHandlerForTesting'; + + MWNamespace::getCanonicalNamespaces( true ); # reset namespace cache + $wgContLang->resetNamespaces(); # reset namespace cache + if ( !$this->the_page ) { $this->the_page = $this->createPage( 'RevisionStorageTest_the_page', "just a dummy page" ); } } + public function tearDown() { + global $wgExtraNamespaces, $wgNamespaceContentModels, $wgContentHandlers, $wgContLang; + + unset( $wgExtraNamespaces[ 12312 ] ); + unset( $wgExtraNamespaces[ 12313 ] ); + + unset( $wgNamespaceContentModels[ 12312 ] ); + unset( $wgContentHandlers[ 'DUMMY' ] ); + + MWNamespace::getCanonicalNamespaces( true ); # reset namespace cache + $wgContLang->resetNamespaces(); # reset namespace cache + } + protected function makeRevision( $props = null ) { if ( $props === null ) $props = array(); @@ -60,7 +85,8 @@ class RevisionStorageTest extends MediaWikiTestCase { $page->doDeleteArticle( "done" ); } - $page->doEdit( $text, "testing", EDIT_NEW ); + $content = ContentHandler::makeContent( $text, $page->getTitle(), $model ); + $page->doEditContent( $content, "testing", EDIT_NEW ); return $page; } @@ -72,6 +98,8 @@ class RevisionStorageTest extends MediaWikiTestCase { $this->assertEquals( $orig->getPage(), $rev->getPage() ); $this->assertEquals( $orig->getTimestamp(), $rev->getTimestamp() ); $this->assertEquals( $orig->getUser(), $rev->getUser() ); + $this->assertEquals( $orig->getContentModel(), $rev->getContentModel() ); + $this->assertEquals( $orig->getContentFormat(), $rev->getContentFormat() ); $this->assertEquals( $orig->getSha1(), $rev->getSha1() ); } @@ -182,6 +210,9 @@ class RevisionStorageTest extends MediaWikiTestCase { $this->assertTrue( in_array( 'rev_page', $fields ), 'missing rev_page in list of fields'); $this->assertTrue( in_array( 'rev_timestamp', $fields ), 'missing rev_timestamp in list of fields'); $this->assertTrue( in_array( 'rev_user', $fields ), 'missing rev_user in list of fields'); + + $this->assertTrue( in_array( 'rev_content_model', $fields ), 'missing rev_content_model in list of fields'); + $this->assertTrue( in_array( 'rev_content_format', $fields ), 'missing rev_content_format in list of fields'); } /** @@ -208,6 +239,17 @@ class RevisionStorageTest extends MediaWikiTestCase { $this->assertEquals( 'hello hello.', $rev->getText() ); } + /** + * @covers Revision::getContent + */ + public function testGetContent() + { + $orig = $this->makeRevision( array( 'text' => 'hello hello.' ) ); + $rev = Revision::newFromId( $orig->getId() ); + + $this->assertEquals( 'hello hello.', $rev->getContent()->getNativeData() ); + } + /** * @covers Revision::revText */ @@ -229,6 +271,29 @@ class RevisionStorageTest extends MediaWikiTestCase { $this->assertEquals( 'hello hello raw.', $rev->getRawText() ); } + + /** + * @covers Revision::getContentModel + */ + public function testGetContentModel() + { + $orig = $this->makeRevision( array( 'text' => 'hello hello.', 'content_model' => CONTENT_MODEL_JAVASCRIPT ) ); + $rev = Revision::newFromId( $orig->getId() ); + + $this->assertEquals( CONTENT_MODEL_JAVASCRIPT, $rev->getContentModel() ); + } + + /** + * @covers Revision::getContentFormat + */ + public function testGetContentFormat() + { + $orig = $this->makeRevision( array( 'text' => 'hello hello.', 'content_model' => CONTENT_MODEL_JAVASCRIPT, 'content_format' => CONTENT_FORMAT_JAVASCRIPT ) ); + $rev = Revision::newFromId( $orig->getId() ); + + $this->assertEquals( CONTENT_FORMAT_JAVASCRIPT, $rev->getContentFormat() ); + } + /** * @covers Revision::isCurrent */ @@ -243,7 +308,7 @@ class RevisionStorageTest extends MediaWikiTestCase { $rev1x = Revision::newFromId( $rev1->getId() ); $this->assertTrue( $rev1x->isCurrent() ); - $page->doEdit( 'Bla bla', 'second rev' ); + $page->doEditContent( ContentHandler::makeContent( 'Bla bla', $page->getTitle() ), 'second rev' ); $rev2 = $page->getRevision(); # @todo: find out if this should be true @@ -266,7 +331,7 @@ class RevisionStorageTest extends MediaWikiTestCase { $this->assertNull( $rev1->getPrevious() ); - $page->doEdit( 'Bla bla', 'second rev testGetPrevious' ); + $page->doEditContent( ContentHandler::makeContent( 'Bla bla', $page->getTitle() ), 'second rev testGetPrevious' ); $rev2 = $page->getRevision(); $this->assertNotNull( $rev2->getPrevious() ); @@ -283,7 +348,7 @@ class RevisionStorageTest extends MediaWikiTestCase { $this->assertNull( $rev1->getNext() ); - $page->doEdit( 'Bla bla', 'second rev testGetNext' ); + $page->doEditContent( ContentHandler::makeContent( 'Bla bla', $page->getTitle() ), 'second rev testGetNext' ); $rev2 = $page->getRevision(); $this->assertNotNull( $rev1->getNext() ); @@ -303,6 +368,6 @@ class RevisionStorageTest extends MediaWikiTestCase { $this->assertNotEquals( $orig->getId(), $rev->getId(), 'new null revision shold have a different id from the original revision' ); $this->assertEquals( $orig->getTextId(), $rev->getTextId(), 'new null revision shold have the same text id as the original revision' ); - $this->assertEquals( 'some testing text', $rev->getText() ); + $this->assertEquals( 'some testing text', $rev->getContent()->getNativeData() ); } } diff --git a/tests/phpunit/includes/RevisionStorageTest_ContentHandlerUseDB.php b/tests/phpunit/includes/RevisionStorageTest_ContentHandlerUseDB.php new file mode 100644 index 0000000000..3dfaa8de3f --- /dev/null +++ b/tests/phpunit/includes/RevisionStorageTest_ContentHandlerUseDB.php @@ -0,0 +1,86 @@ +saveContentHandlerNoDB = $wgContentHandlerUseDB; + + $wgContentHandlerUseDB = false; + + $dbw = wfGetDB( DB_MASTER ); + + $page_table = $dbw->tableName( 'page' ); + $revision_table = $dbw->tableName( 'revision' ); + $archive_table = $dbw->tableName( 'archive' ); + + if ( $dbw->fieldExists( $page_table, 'page_content_model' ) ) { + $dbw->query( "alter table $page_table drop column page_content_model" ); + $dbw->query( "alter table $revision_table drop column rev_content_model" ); + $dbw->query( "alter table $revision_table drop column rev_content_format" ); + $dbw->query( "alter table $archive_table drop column ar_content_model" ); + $dbw->query( "alter table $archive_table drop column ar_content_format" ); + } + + parent::setUp(); + } + + function tearDown() { + global $wgContentHandlerUseDB; + + parent::tearDown(); + + $wgContentHandlerUseDB = $this->saveContentHandlerNoDB; + } + + /** + * @covers Revision::selectFields + */ + public function testSelectFields() + { + $fields = Revision::selectFields(); + + $this->assertTrue( in_array( 'rev_id', $fields ), 'missing rev_id in list of fields'); + $this->assertTrue( in_array( 'rev_page', $fields ), 'missing rev_page in list of fields'); + $this->assertTrue( in_array( 'rev_timestamp', $fields ), 'missing rev_timestamp in list of fields'); + $this->assertTrue( in_array( 'rev_user', $fields ), 'missing rev_user in list of fields'); + + $this->assertFalse( in_array( 'rev_content_model', $fields ), 'missing rev_content_model in list of fields'); + $this->assertFalse( in_array( 'rev_content_format', $fields ), 'missing rev_content_format in list of fields'); + } + + /** + * @covers Revision::getContentModel + */ + public function testGetContentModel() + { + $orig = $this->makeRevision( array( 'text' => 'hello hello.', 'content_model' => CONTENT_MODEL_JAVASCRIPT ) ); + $rev = Revision::newFromId( $orig->getId() ); + + //NOTE: database fields for the content_model are disabled, so the model name is not retained. + // We expect to get the default here instead of what was suppleid when creating the revision. + $this->assertEquals( CONTENT_MODEL_WIKITEXT, $rev->getContentModel() ); + } + + + /** + * @covers Revision::getContentFormat + */ + public function testGetContentFormat() + { + $orig = $this->makeRevision( array( 'text' => 'hello hello.', 'content_model' => CONTENT_MODEL_JAVASCRIPT, 'content_format' => 'text/javascript' ) ); + $rev = Revision::newFromId( $orig->getId() ); + + $this->assertEquals( CONTENT_FORMAT_WIKITEXT, $rev->getContentFormat() ); + } + +} + + diff --git a/tests/phpunit/includes/RevisionTest.php b/tests/phpunit/includes/RevisionTest.php index d7654db9f2..ed7d919a5a 100644 --- a/tests/phpunit/includes/RevisionTest.php +++ b/tests/phpunit/includes/RevisionTest.php @@ -1,25 +1,53 @@ false, 'wgCompressRevisions' => false, + + 'wgContentHandlerTextFallback' => $GLOBALS['wgContentHandlerTextFallback'], + 'wgExtraNamespaces' => $GLOBALS['wgExtraNamespaces'], + 'wgNamespaceContentModels' => $GLOBALS['wgNamespaceContentModels'], + 'wgContentHandlers' => $GLOBALS['wgContentHandlers'], ); + foreach ( $globalSet as $var => $data ) { $this->saveGlobals[$var] = $GLOBALS[$var]; $GLOBALS[$var] = $data; } + + global $wgExtraNamespaces, $wgNamespaceContentModels, $wgContentHandlers, $wgContLang; + $wgExtraNamespaces[ 12312 ] = 'Dummy'; + $wgExtraNamespaces[ 12313 ] = 'Dummy_talk'; + + $wgNamespaceContentModels[ 12312 ] = 999999; + $wgContentHandlers[ 999999 ] = 'DummyContentHandlerForTesting'; + + MWNamespace::getCanonicalNamespaces( true ); # reset namespace cache + $wgContLang->resetNamespaces(); # reset namespace cache + + global $wgContentHandlerTextFallback; + $wgContentHandlerTextFallback = 'ignore'; } function tearDown() { + global $wgContLang; + foreach ( $this->saveGlobals as $var => $data ) { $GLOBALS[$var] = $data; } + + MWNamespace::getCanonicalNamespaces( true ); # reset namespace cache + $wgContLang->resetNamespaces(); # reset namespace cache } function testGetRevisionText() { @@ -120,6 +148,196 @@ class RevisionTest extends MediaWikiTestCase { $this->assertEquals( "Wiki est l'\xc3\xa9cole superieur !", Revision::getRevisionText( $row ), "getRevisionText" ); } + + # ================================================================================================================= + + /** + * @param string $text + * @param string $title + * @param string $model + * @return Revision + */ + function newTestRevision( $text, $title = "Test", $model = CONTENT_MODEL_WIKITEXT, $format = null ) { + if ( is_string( $title ) ) { + $title = Title::newFromText( $title ); + } + + $content = ContentHandler::makeContent( $text, $title, $model, $format ); + + $rev = new Revision( + array( + 'id' => 42, + 'page' => 23, + 'title' => $title, + + 'content' => $content, + 'length' => $content->getSize(), + 'comment' => "testing", + 'minor_edit' => false, + + 'content_format' => $format, + ) + ); + + return $rev; + } + + function dataGetContentModel() { + return array( + array( 'hello world', 'Hello', null, null, CONTENT_MODEL_WIKITEXT ), + array( 'hello world', 'User:hello/there.css', null, null, CONTENT_MODEL_CSS ), + array( serialize('hello world'), 'Dummy:Hello', null, null, 999999 ), + ); + } + + /** + * @dataProvider dataGetContentModel + */ + function testGetContentModel( $text, $title, $model, $format, $expectedModel ) { + $rev = $this->newTestRevision( $text, $title, $model, $format ); + + $this->assertEquals( $expectedModel, $rev->getContentModel() ); + } + + function dataGetContentFormat() { + return array( + array( 'hello world', 'Hello', null, null, CONTENT_FORMAT_WIKITEXT ), + array( 'hello world', 'Hello', CONTENT_MODEL_CSS, null, CONTENT_FORMAT_CSS ), + array( 'hello world', 'User:hello/there.css', null, null, CONTENT_FORMAT_CSS ), + array( serialize('hello world'), 'Dummy:Hello', null, null, 999999 ), + ); + } + + /** + * @dataProvider dataGetContentFormat + */ + function testGetContentFormat( $text, $title, $model, $format, $expectedFormat ) { + $rev = $this->newTestRevision( $text, $title, $model, $format ); + + $this->assertEquals( $expectedFormat, $rev->getContentFormat() ); + } + + function dataGetContentHandler() { + return array( + array( 'hello world', 'Hello', null, null, 'WikitextContentHandler' ), + array( 'hello world', 'User:hello/there.css', null, null, 'CssContentHandler' ), + array( serialize('hello world'), 'Dummy:Hello', null, null, 'DummyContentHandlerForTesting' ), + ); + } + + /** + * @dataProvider dataGetContentHandler + */ + function testGetContentHandler( $text, $title, $model, $format, $expectedClass ) { + $rev = $this->newTestRevision( $text, $title, $model, $format ); + + $this->assertEquals( $expectedClass, get_class( $rev->getContentHandler() ) ); + } + + function dataGetContent() { + return array( + array( 'hello world', 'Hello', null, null, Revision::FOR_PUBLIC, 'hello world' ), + array( serialize('hello world'), 'Hello', 999999, null, Revision::FOR_PUBLIC, serialize('hello world') ), + array( serialize('hello world'), 'Dummy:Hello', null, null, Revision::FOR_PUBLIC, serialize('hello world') ), + ); + } + + /** + * @dataProvider dataGetContent + */ + function testGetContent( $text, $title, $model, $format, $audience, $expectedSerialization ) { + $rev = $this->newTestRevision( $text, $title, $model, $format ); + $content = $rev->getContent( $audience ); + + $this->assertEquals( $expectedSerialization, is_null( $content ) ? null : $content->serialize( $format ) ); + } + + function dataGetText() { + return array( + array( 'hello world', 'Hello', null, null, Revision::FOR_PUBLIC, 'hello world' ), + array( serialize('hello world'), 'Hello', 999999, null, Revision::FOR_PUBLIC, null ), + array( serialize('hello world'), 'Dummy:Hello', null, null, Revision::FOR_PUBLIC, null ), + ); + } + + /** + * @dataProvider dataGetText + */ + function testGetText( $text, $title, $model, $format, $audience, $expectedText ) { + $rev = $this->newTestRevision( $text, $title, $model, $format ); + + $this->assertEquals( $expectedText, $rev->getText( $audience ) ); + } + + /** + * @dataProvider dataGetText + */ + function testGetRawText( $text, $title, $model, $format, $audience, $expectedText ) { + $rev = $this->newTestRevision( $text, $title, $model, $format ); + + $this->assertEquals( $expectedText, $rev->getRawText( $audience ) ); + } + + + public function dataGetSize( ) { + return array( + array( "hello world.", null, 12 ), + array( serialize( "hello world." ), 999999, 12 ), + ); + } + + /** + * @covers Revision::getSize + * @dataProvider dataGetSize + */ + public function testGetSize( $text, $model, $expected_size ) + { + $rev = $this->newTestRevision( $text, 'RevisionTest_testGetSize', $model ); + $this->assertEquals( $expected_size, $rev->getSize() ); + } + + public function dataGetSha1( ) { + return array( + array( "hello world.", null, Revision::base36Sha1( "hello world." ) ), + array( serialize( "hello world." ), 999999, Revision::base36Sha1( serialize( "hello world." ) ) ), + ); + } + + /** + * @covers Revision::getSha1 + * @dataProvider dataGetSha1 + */ + public function testGetSha1( $text, $model, $expected_hash ) + { + $rev = $this->newTestRevision( $text, 'RevisionTest_testGetSha1', $model ); + $this->assertEquals( $expected_hash, $rev->getSha1() ); + } + + public function testConstructWithText() { + $rev = new Revision( array( + 'text' => 'hello world.', + 'content_model' => CONTENT_MODEL_JAVASCRIPT + )); + + $this->assertNotNull( $rev->getText(), 'no content text' ); + $this->assertNotNull( $rev->getContent(), 'no content object available' ); + $this->assertEquals( CONTENT_MODEL_JAVASCRIPT, $rev->getContent()->getModel() ); + $this->assertEquals( CONTENT_MODEL_JAVASCRIPT, $rev->getContentModel() ); + } + + public function testConstructWithContent() { + $title = Title::newFromText( 'RevisionTest_testConstructWithContent' ); + + $rev = new Revision( array( + 'content' => ContentHandler::makeContent( 'hello world.', $title, CONTENT_MODEL_JAVASCRIPT ), + )); + + $this->assertNotNull( $rev->getText(), 'no content text' ); + $this->assertNotNull( $rev->getContent(), 'no content object available' ); + $this->assertEquals( CONTENT_MODEL_JAVASCRIPT, $rev->getContent()->getModel() ); + $this->assertEquals( CONTENT_MODEL_JAVASCRIPT, $rev->getContentModel() ); + } + } diff --git a/tests/phpunit/includes/TitleMethodsTest.php b/tests/phpunit/includes/TitleMethodsTest.php index aed658baa3..de8f18fb5b 100644 --- a/tests/phpunit/includes/TitleMethodsTest.php +++ b/tests/phpunit/includes/TitleMethodsTest.php @@ -1,7 +1,34 @@ resetNamespaces(); # reset namespace cache + } + + public function teardown() { + global $wgExtraNamespaces, $wgNamespaceContentModels, $wgContLang; + + unset( $wgExtraNamespaces[ 12302 ] ); + unset( $wgExtraNamespaces[ 12303 ] ); + + unset( $wgNamespaceContentModels[ 12302 ] ); + + MWNamespace::getCanonicalNamespaces( true ); # reset namespace cache + $wgContLang->resetNamespaces(); # reset namespace cache + } + public function dataEquals() { return array( array( 'Main Page', 'Main Page', true ), @@ -75,6 +102,47 @@ class TitleMethodsTest extends MediaWikiTestCase { $this->assertEquals( $expectedBool, $title->hasSubjectNamespace( $ns ) ); } + public function dataGetContentModel() { + return array( + array( 'Foo', CONTENT_MODEL_WIKITEXT ), + array( 'Foo.js', CONTENT_MODEL_WIKITEXT ), + array( 'Foo/bar.js', CONTENT_MODEL_WIKITEXT ), + array( 'User:Foo', CONTENT_MODEL_WIKITEXT ), + array( 'User:Foo.js', CONTENT_MODEL_WIKITEXT ), + array( 'User:Foo/bar.js', CONTENT_MODEL_JAVASCRIPT ), + array( 'User:Foo/bar.css', CONTENT_MODEL_CSS ), + array( 'User talk:Foo/bar.css', CONTENT_MODEL_WIKITEXT ), + array( 'User:Foo/bar.js.xxx', CONTENT_MODEL_WIKITEXT ), + array( 'User:Foo/bar.xxx', CONTENT_MODEL_WIKITEXT ), + array( 'MediaWiki:Foo.js', CONTENT_MODEL_JAVASCRIPT ), + array( 'MediaWiki:Foo.css', CONTENT_MODEL_CSS ), + array( 'MediaWiki:Foo/bar.css', CONTENT_MODEL_CSS ), + array( 'MediaWiki:Foo.JS', CONTENT_MODEL_WIKITEXT ), + array( 'MediaWiki:Foo.CSS', CONTENT_MODEL_WIKITEXT ), + array( 'MediaWiki:Foo.css.xxx', CONTENT_MODEL_WIKITEXT ), + array( 'TEST-JS:Foo', CONTENT_MODEL_JAVASCRIPT ), + array( 'TEST-JS:Foo.js', CONTENT_MODEL_JAVASCRIPT ), + array( 'TEST-JS:Foo/bar.js', CONTENT_MODEL_JAVASCRIPT ), + array( 'TEST-JS_TALK:Foo.js', CONTENT_MODEL_WIKITEXT ), + ); + } + + /** + * @dataProvider dataGetContentModel + */ + public function testGetContentModel( $title, $expectedModelId ) { + $title = Title::newFromText( $title ); + $this->assertEquals( $expectedModelId, $title->getContentModel() ); + } + + /** + * @dataProvider dataGetContentModel + */ + public function testHasContentModel( $title, $expectedModelId ) { + $title = Title::newFromText( $title ); + $this->assertTrue( $title->hasContentModel( $expectedModelId ) ); + } + public function dataIsCssOrJsPage() { return array( array( 'Foo', false ), @@ -92,6 +160,8 @@ class TitleMethodsTest extends MediaWikiTestCase { array( 'MediaWiki:Foo.JS', false ), array( 'MediaWiki:Foo.CSS', false ), array( 'MediaWiki:Foo.css.xxx', false ), + array( 'TEST-JS:Foo', false ), + array( 'TEST-JS:Foo.js', false ), ); } @@ -119,6 +189,8 @@ class TitleMethodsTest extends MediaWikiTestCase { array( 'MediaWiki:Foo.js', false ), array( 'User:Foo/bar.JS', false ), array( 'User:Foo/bar.CSS', false ), + array( 'TEST-JS:Foo', false ), + array( 'TEST-JS:Foo.js', false ), ); } @@ -187,6 +259,9 @@ class TitleMethodsTest extends MediaWikiTestCase { array( 'MediaWiki:Foo/bar.css', false ), array( 'User:Foo/bar.JS', true ), array( 'User:Foo/bar.CSS', true ), + array( 'TEST-JS:Foo', false ), + array( 'TEST-JS:Foo.js', false ), + array( 'TEST-JS_TALK:Foo.js', true ), ); } diff --git a/tests/phpunit/includes/WikiPageTest.php b/tests/phpunit/includes/WikiPageTest.php index c8606cc745..e2d3605af7 100644 --- a/tests/phpunit/includes/WikiPageTest.php +++ b/tests/phpunit/includes/WikiPageTest.php @@ -1,5 +1,6 @@ pages_to_delete = array(); + + LinkCache::singleton()->clear(); # avoid cached redirect status, etc } public function tearDown() { @@ -49,6 +52,10 @@ class WikiPageTest extends MediaWikiLangTestCase { parent::tearDown(); } + /** + * @param Title $title + * @return WikiPage + */ protected function newPage( $title ) { if ( is_string( $title ) ) $title = Title::newFromText( $title ); @@ -59,15 +66,84 @@ class WikiPageTest extends MediaWikiLangTestCase { return $p; } + + /** + * @param String|Title|WikiPage $page + * @param String $text + * @param int $model + * + * @return WikiPage + */ protected function createPage( $page, $text, $model = null ) { if ( is_string( $page ) ) $page = Title::newFromText( $page ); - if ( $page instanceof Title ) $page = $this->newPage( $page ); - $page->doEdit( $text, "testing", EDIT_NEW ); + if ( $page instanceof Title ) { + $title = $page; + $page = $this->newPage( $page ); + } else { + $title = null; + } + + $content = ContentHandler::makeContent( $text, $page->getTitle(), $model ); + $page->doEditContent( $content, "testing", EDIT_NEW ); return $page; } + public function testDoEditContent() { + $title = Title::newFromText( "WikiPageTest_testDoEditContent" ); + + $page = $this->newPage( $title ); + + $content = ContentHandler::makeContent( "[[Lorem ipsum]] dolor sit amet, consetetur sadipscing elitr, sed diam " + . " nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat.", + $title ); + + $page->doEditContent( $content, "[[testing]] 1" ); + + $this->assertTrue( $title->getArticleID() > 0, "Title object should have new page id" ); + $this->assertTrue( $page->getId() > 0, "WikiPage should have new page id" ); + $this->assertTrue( $title->exists(), "Title object should indicate that the page now exists" ); + $this->assertTrue( $page->exists(), "WikiPage object should indicate that the page now exists" ); + + $id = $page->getId(); + + # ------------------------ + $dbr = wfGetDB( DB_SLAVE ); + $res = $dbr->select( 'pagelinks', '*', array( 'pl_from' => $id ) ); + $n = $res->numRows(); + $res->free(); + + $this->assertEquals( 1, $n, 'pagelinks should contain one link from the page' ); + + # ------------------------ + $page = new WikiPage( $title ); + + $retrieved = $page->getContent(); + $this->assertTrue( $content->equals( $retrieved ), 'retrieved content doesn\'t equal original' ); + + # ------------------------ + $content = ContentHandler::makeContent( "At vero eos et accusam et justo duo [[dolores]] et ea rebum. " + . "Stet clita kasd [[gubergren]], no sea takimata sanctus est.", + $title ); + + $page->doEditContent( $content, "testing 2" ); + + # ------------------------ + $page = new WikiPage( $title ); + + $retrieved = $page->getContent(); + $this->assertTrue( $content->equals( $retrieved ), 'retrieved content doesn\'t equal original' ); + + # ------------------------ + $dbr = wfGetDB( DB_SLAVE ); + $res = $dbr->select( 'pagelinks', '*', array( 'pl_from' => $id ) ); + $n = $res->numRows(); + $res->free(); + + $this->assertEquals( 2, $n, 'pagelinks should contain two links from the page' ); + } + public function testDoEdit() { $title = Title::newFromText( "WikiPageTest_testDoEdit" ); @@ -76,13 +152,23 @@ class WikiPageTest extends MediaWikiLangTestCase { $text = "[[Lorem ipsum]] dolor sit amet, consetetur sadipscing elitr, sed diam " . " nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat."; - $page->doEdit( $text, "testing 1" ); + $page->doEdit( $text, "[[testing]] 1" ); + $this->assertTrue( $title->getArticleID() > 0, "Title object should have new page id" ); + $this->assertTrue( $page->getId() > 0, "WikiPage should have new page id" ); $this->assertTrue( $title->exists(), "Title object should indicate that the page now exists" ); $this->assertTrue( $page->exists(), "WikiPage object should indicate that the page now exists" ); $id = $page->getId(); + # ------------------------ + $dbr = wfGetDB( DB_SLAVE ); + $res = $dbr->select( 'pagelinks', '*', array( 'pl_from' => $id ) ); + $n = $res->numRows(); + $res->free(); + + $this->assertEquals( 1, $n, 'pagelinks should contain one link from the page' ); + # ------------------------ $page = new WikiPage( $title ); @@ -123,13 +209,29 @@ class WikiPageTest extends MediaWikiLangTestCase { $this->assertEquals( $text, $page->getText() ); } + public function testDoQuickEditContent() { + global $wgUser; + + $page = $this->createPage( "WikiPageTest_testDoQuickEditContent", "original text" ); + + $content = ContentHandler::makeContent( "quick text", $page->getTitle() ); + $page->doQuickEditContent( $content, $wgUser, "testing q" ); + + # --------------------- + $page = new WikiPage( $page->getTitle() ); + $this->assertTrue( $content->equals( $page->getContent() ) ); + } + public function testDoDeleteArticle() { $page = $this->createPage( "WikiPageTest_testDoDeleteArticle", "[[original text]] foo" ); $id = $page->getId(); $page->doDeleteArticle( "testing deletion" ); + $this->assertFalse( $page->getTitle()->getArticleID() > 0, "Title object should now have page id 0" ); + $this->assertFalse( $page->getId() > 0, "WikiPage should now have page id 0" ); $this->assertFalse( $page->exists(), "WikiPage::exists should return false after page was deleted" ); + $this->assertNull( $page->getContent(), "WikiPage::getContent should return null after page was deleted" ); $this->assertFalse( $page->getText(), "WikiPage::getText should return false after page was deleted" ); $t = Title::newFromText( $page->getTitle()->getPrefixedText() ); @@ -171,7 +273,20 @@ class WikiPageTest extends MediaWikiLangTestCase { $rev = $page->getRevision(); $this->assertEquals( $page->getLatest(), $rev->getId() ); - $this->assertEquals( "some text", $rev->getText() ); + $this->assertEquals( "some text", $rev->getContent()->getNativeData() ); + } + + public function testGetContent() { + $page = $this->newPage( "WikiPageTest_testGetContent" ); + + $content = $page->getContent(); + $this->assertNull( $content ); + + # ----------------- + $this->createPage( $page, "some text" ); + + $content = $page->getContent(); + $this->assertEquals( "some text", $content->getNativeData() ); } public function testGetText() { @@ -200,7 +315,20 @@ class WikiPageTest extends MediaWikiLangTestCase { $this->assertEquals( "some text", $text ); } - + public function testGetContentModel() { + $page = $this->createPage( "WikiPageTest_testGetContentModel", "some text", CONTENT_MODEL_JAVASCRIPT ); + + $page = new WikiPage( $page->getTitle() ); + $this->assertEquals( CONTENT_MODEL_JAVASCRIPT, $page->getContentModel() ); + } + + public function testGetContentHandler() { + $page = $this->createPage( "WikiPageTest_testGetContentHandler", "some text", CONTENT_MODEL_JAVASCRIPT ); + + $page = new WikiPage( $page->getTitle() ); + $this->assertEquals( 'JavaScriptContentHandler', get_class( $page->getContentHandler() ) ); + } + public function testExists() { $page = $this->newPage( "WikiPageTest_testExists" ); $this->assertFalse( $page->exists() ); @@ -259,6 +387,10 @@ class WikiPageTest extends MediaWikiLangTestCase { public function testGetRedirectTarget( $title, $text, $target ) { $page = $this->createPage( $title, $text ); + # sanity check, because this test seems to fail for no reason for some people. + $c = $page->getContent(); + $this->assertEquals( 'WikitextContent', get_class( $c ) ); + # now, test the actual redirect $t = $page->getRedirectTarget(); $this->assertEquals( $target, is_null( $t ) ? null : $t->getPrefixedText() ); @@ -371,15 +503,19 @@ class WikiPageTest extends MediaWikiLangTestCase { public function testIsCountable( $title, $text, $mode, $expected ) { global $wgArticleCountMethod; - $old = $wgArticleCountMethod; + $oldArticleCountMethod = $wgArticleCountMethod; $wgArticleCountMethod = $mode; $page = $this->createPage( $title, $text ); - $editInfo = $page->prepareTextForEdit( $page->getText() ); + $hasLinks = wfGetDB( DB_SLAVE )->selectField( 'pagelinks', 1, + array( 'pl_from' => $page->getId() ), __METHOD__ ); + + $editInfo = $page->prepareContentForEdit( $page->getContent() ); $v = $page->isCountable(); $w = $page->isCountable( $editInfo ); - $wgArticleCountMethod = $old; + + $wgArticleCountMethod = $oldArticleCountMethod; $this->assertEquals( $expected, $v, "isCountable( null ) returned unexpected value " . var_export( $v, true ) . " instead of " . var_export( $expected, true ) . " in mode `$mode` for text \"$text\"" ); @@ -478,6 +614,18 @@ more stuff $this->assertEquals( $expected, $text ); } + /** + * @dataProvider dataReplaceSection + */ + public function testReplaceSectionContent( $title, $text, $section, $with, $sectionTitle, $expected ) { + $page = $this->createPage( $title, $text ); + + $content = ContentHandler::makeContent( $with, $page->getTitle(), $page->getContentModel() ); + $c = $page->replaceSectionContent( $section, $content, $sectionTitle ); + + $this->assertEquals( $expected, is_null( $c ) ? null : trim( $c->getNativeData() ) ); + } + /* @FIXME: fix this! public function testGetUndoText() { global $wgDiff3; @@ -538,19 +686,19 @@ more stuff $text = "one"; $page = $this->newPage( "WikiPageTest_testDoRollback" ); - $page->doEdit( $text, "section one", EDIT_NEW, false, $admin ); + $page->doEditContent( ContentHandler::makeContent( $text, $page->getTitle() ), "section one", EDIT_NEW, false, $admin ); $user1 = new User(); $user1->setName( "127.0.1.11" ); $text .= "\n\ntwo"; $page = new WikiPage( $page->getTitle() ); - $page->doEdit( $text, "adding section two", 0, false, $user1 ); + $page->doEditContent( ContentHandler::makeContent( $text, $page->getTitle() ), "adding section two", 0, false, $user1 ); $user2 = new User(); $user2->setName( "127.0.2.13" ); $text .= "\n\nthree"; $page = new WikiPage( $page->getTitle() ); - $page->doEdit( $text, "adding section three", 0, false, $user2 ); + $page->doEditContent( ContentHandler::makeContent( $text, $page->getTitle() ), "adding section three", 0, false, $user2 ); # we are having issues with doRollback spuriously failing. apparently the last revision somehow goes missing # or not committed under some circumstances. so, make sure the last revision has the right user name. @@ -578,7 +726,7 @@ more stuff $page = new WikiPage( $page->getTitle() ); $this->assertEquals( $rev2->getSha1(), $page->getRevision()->getSha1(), "rollback did not revert to the correct revision" ); - $this->assertEquals( "one\n\ntwo", $page->getText() ); + $this->assertEquals( "one\n\ntwo", $page->getContent()->getNativeData() ); } /** @@ -590,14 +738,14 @@ more stuff $text = "one"; $page = $this->newPage( "WikiPageTest_testDoRollback" ); - $page->doEdit( $text, "section one", EDIT_NEW, false, $admin ); + $page->doEditContent( ContentHandler::makeContent( $text, $page->getTitle() ), "section one", EDIT_NEW, false, $admin ); $rev1 = $page->getRevision(); $user1 = new User(); $user1->setName( "127.0.1.11" ); $text .= "\n\ntwo"; $page = new WikiPage( $page->getTitle() ); - $page->doEdit( $text, "adding section two", 0, false, $user1 ); + $page->doEditContent( ContentHandler::makeContent( $text, $page->getTitle() ), "adding section two", 0, false, $user1 ); # now, try the rollback $admin->addGroup( "sysop" ); #XXX: make the test user a sysop... @@ -610,7 +758,7 @@ more stuff $page = new WikiPage( $page->getTitle() ); $this->assertEquals( $rev1->getSha1(), $page->getRevision()->getSha1(), "rollback did not revert to the correct revision" ); - $this->assertEquals( "one", $page->getText() ); + $this->assertEquals( "one", $page->getContent()->getNativeData() ); } public function dataGetAutosummary( ) { @@ -739,7 +887,9 @@ more stuff if ( !empty( $edit[1] ) ) $user->setName( $edit[1] ); else $user = $wgUser; - $page->doEdit( $edit[0], "test edit $c", $c < 2 ? EDIT_NEW : 0, false, $user ); + $content = ContentHandler::makeContent( $edit[0], $page->getTitle(), $page->getContentModel() ); + + $page->doEditContent( $content, "test edit $c", $c < 2 ? EDIT_NEW : 0, false, $user ); $c += 1; } diff --git a/tests/phpunit/includes/WikiPageTest_ContentHandlerUseDB.php b/tests/phpunit/includes/WikiPageTest_ContentHandlerUseDB.php new file mode 100644 index 0000000000..1af6806b79 --- /dev/null +++ b/tests/phpunit/includes/WikiPageTest_ContentHandlerUseDB.php @@ -0,0 +1,64 @@ +saveContentHandlerNoDB = $wgContentHandlerUseDB; + + $wgContentHandlerUseDB = false; + + $dbw = wfGetDB( DB_MASTER ); + + $page_table = $dbw->tableName( 'page' ); + $revision_table = $dbw->tableName( 'revision' ); + $archive_table = $dbw->tableName( 'archive' ); + + if ( $dbw->fieldExists( $page_table, 'page_content_model' ) ) { + $dbw->query( "alter table $page_table drop column page_content_model" ); + $dbw->query( "alter table $revision_table drop column rev_content_model" ); + $dbw->query( "alter table $revision_table drop column rev_content_format" ); + $dbw->query( "alter table $archive_table drop column ar_content_model" ); + $dbw->query( "alter table $archive_table drop column ar_content_format" ); + } + } + + function tearDown() { + global $wgContentHandlerUseDB; + + $wgContentHandlerUseDB = $this->saveContentHandlerNoDB; + + parent::tearDown(); + } + + public function testGetContentModel() { + $page = $this->createPage( "WikiPageTest_testGetContentModel", "some text", CONTENT_MODEL_JAVASCRIPT ); + + $page = new WikiPage( $page->getTitle() ); + + // NOTE: since the content model is not recorded in the database, + // we expect to get the default, namely CONTENT_MODEL_WIKITEXT + $this->assertEquals( CONTENT_MODEL_WIKITEXT, $page->getContentModel() ); + } + + public function testGetContentHandler() { + $page = $this->createPage( "WikiPageTest_testGetContentHandler", "some text", CONTENT_MODEL_JAVASCRIPT ); + + // NOTE: since the content model is not recorded in the database, + // we expect to get the default, namely CONTENT_MODEL_WIKITEXT + $page = new WikiPage( $page->getTitle() ); + $this->assertEquals( 'WikitextContentHandler', get_class( $page->getContentHandler() ) ); + } + +} + + diff --git a/tests/phpunit/includes/WikitextContentHandlerTest.php b/tests/phpunit/includes/WikitextContentHandlerTest.php new file mode 100644 index 0000000000..a7615cfb48 --- /dev/null +++ b/tests/phpunit/includes/WikitextContentHandlerTest.php @@ -0,0 +1,199 @@ +handler = ContentHandler::getForModelID( CONTENT_MODEL_WIKITEXT ); + } + + public function teardown() { + } + + public function testSerializeContent( ) { + $content = new WikitextContent( 'hello world' ); + + $this->assertEquals( 'hello world', $this->handler->serializeContent( $content ) ); + $this->assertEquals( 'hello world', $this->handler->serializeContent( $content, CONTENT_FORMAT_WIKITEXT ) ); + + try { + $this->handler->serializeContent( $content, 'dummy/foo' ); + $this->fail( "serializeContent() should have failed on unknown format" ); + } catch ( MWException $e ) { + // ok, as expected + } + } + + public function testUnserializeContent( ) { + $content = $this->handler->unserializeContent( 'hello world' ); + $this->assertEquals( 'hello world', $content->getNativeData() ); + + $content = $this->handler->unserializeContent( 'hello world', CONTENT_FORMAT_WIKITEXT ); + $this->assertEquals( 'hello world', $content->getNativeData() ); + + try { + $this->handler->unserializeContent( 'hello world', 'dummy/foo' ); + $this->fail( "unserializeContent() should have failed on unknown format" ); + } catch ( MWException $e ) { + // ok, as expected + } + } + + public function testMakeEmptyContent() { + $content = $this->handler->makeEmptyContent(); + + $this->assertTrue( $content->isEmpty() ); + $this->assertEquals( '', $content->getNativeData() ); + } + + public function dataIsSupportedFormat( ) { + return array( + array( null, true ), + array( CONTENT_FORMAT_WIKITEXT, true ), + array( 99887766, false ), + ); + } + + /** + * @dataProvider dataIsSupportedFormat + */ + public function testIsSupportedFormat( $format, $supported ) { + $this->assertEquals( $supported, $this->handler->isSupportedFormat( $format ) ); + } + + public function dataMerge3( ) { + return array( + array( "first paragraph + + second paragraph\n", + + "FIRST paragraph + + second paragraph\n", + + "first paragraph + + SECOND paragraph\n", + + "FIRST paragraph + + SECOND paragraph\n", + ), + + array( "first paragraph + second paragraph\n", + + "Bla bla\n", + + "Blubberdibla\n", + + false, + ), + + ); + } + + /** + * @dataProvider dataMerge3 + */ + public function testMerge3( $old, $mine, $yours, $expected ) { + global $wgDiff3; + + if ( !$wgDiff3 ) { + $this->markTestSkipped( "Can't test merge3(), since \$wgDiff3 is not configured" ); + } + + if ( !file_exists( $wgDiff3 ) ) { + #XXX: this sucks, since it uses arcane internal knowledge about TextContentHandler::merge3 and wfMerge. + $this->markTestSkipped( "Can't test merge3(), since \$wgDiff3 is misconfigured: can't find $wgDiff3" ); + } + + // test merge + $oldContent = new WikitextContent( $old ); + $myContent = new WikitextContent( $mine ); + $yourContent = new WikitextContent( $yours ); + + $merged = $this->handler->merge3( $oldContent, $myContent, $yourContent ); + + $this->assertEquals( $expected, $merged ? $merged->getNativeData() : $merged ); + } + + public function dataGetAutosummary( ) { + return array( + array( + 'Hello there, world!', + '#REDIRECT [[Foo]]', + 0, + '/^Redirected page .*Foo/' + ), + + array( + null, + 'Hello world!', + EDIT_NEW, + '/^Created page .*Hello/' + ), + + array( + 'Hello there, world!', + '', + 0, + '/^Blanked/' + ), + + array( + 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut + labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et + ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.', + 'Hello world!', + 0, + '/^Replaced .*Hello/' + ), + + array( + 'foo', + 'bar', + 0, + '/^$/' + ), + ); + } + + /** + * @dataProvider dataGetAutoSummary + */ + public function testGetAutosummary( $old, $new, $flags, $expected ) { + global $wgLanguageCode, $wgContLang; + + $oldContent = is_null( $old ) ? null : new WikitextContent( $old ); + $newContent = is_null( $new ) ? null : new WikitextContent( $new ); + + $summary = $this->handler->getAutosummary( $oldContent, $newContent, $flags ); + + $this->assertTrue( (bool)preg_match( $expected, $summary ), "Autosummary didn't match expected pattern $expected: $summary" ); + } + + /** + * @todo Text case required database! + */ + /* + public function testGetAutoDeleteReason( Title $title, &$hasHistory ) { + } + */ + + /** + * @todo Text case required database! + */ + /* + public function testGetUndoContent( Revision $current, Revision $undo, Revision $undoafter = null ) { + } + */ + +} diff --git a/tests/phpunit/includes/WikitextContentTest.php b/tests/phpunit/includes/WikitextContentTest.php new file mode 100644 index 0000000000..5feec832e7 --- /dev/null +++ b/tests/phpunit/includes/WikitextContentTest.php @@ -0,0 +1,421 @@ +context = new RequestContext( new FauxRequest() ); + $this->context->setTitle( Title::newFromText( "Test" ) ); + } + + public function newContent( $text ) { + return new WikitextContent( $text ); + } + + public function dataGetParserOutput() { + return array( + array("hello ''world''\n", "

hello world\n

"), + // @todo: more...? + ); + } + + /** + * @dataProvider dataGetParserOutput + */ + public function testGetParserOutput( $text, $expectedHtml ) { + $content = $this->newContent( $text ); + + $po = $content->getParserOutput( $this->context->getTitle() ); + + $this->assertEquals( $expectedHtml, $po->getText() ); + return $po; + } + + static $sections = + +"Intro + +== stuff == +hello world + +== test == +just a test + +== foo == +more stuff +"; + + public function dataGetSection() { + return array( + array( WikitextContentTest::$sections, + "0", + "Intro" + ), + array( WikitextContentTest::$sections, + "2", +"== test == +just a test" + ), + array( WikitextContentTest::$sections, + "8", + false + ), + ); + } + + /** + * @dataProvider dataGetSection + */ + public function testGetSection( $text, $sectionId, $expectedText ) { + $content = $this->newContent( $text ); + + $sectionContent = $content->getSection( $sectionId ); + + $this->assertEquals( $expectedText, is_null( $sectionContent ) ? null : $sectionContent->getNativeData() ); + } + + public function dataReplaceSection() { + return array( + array( WikitextContentTest::$sections, + "0", + "No more", + null, + trim( preg_replace( '/^Intro/sm', 'No more', WikitextContentTest::$sections ) ) + ), + array( WikitextContentTest::$sections, + "", + "No more", + null, + "No more" + ), + array( WikitextContentTest::$sections, + "2", + "== TEST ==\nmore fun", + null, + trim( preg_replace( '/^== test ==.*== foo ==/sm', "== TEST ==\nmore fun\n\n== foo ==", WikitextContentTest::$sections ) ) + ), + array( WikitextContentTest::$sections, + "8", + "No more", + null, + WikitextContentTest::$sections + ), + array( WikitextContentTest::$sections, + "new", + "No more", + "New", + trim( WikitextContentTest::$sections ) . "\n\n\n== New ==\n\nNo more" + ), + ); + } + + /** + * @dataProvider dataReplaceSection + */ + public function testReplaceSection( $text, $section, $with, $sectionTitle, $expected ) { + $content = $this->newContent( $text ); + $c = $content->replaceSection( $section, $this->newContent( $with ), $sectionTitle ); + + $this->assertEquals( $expected, is_null( $c ) ? null : $c->getNativeData() ); + } + + public function testAddSectionHeader( ) { + $content = $this->newContent( 'hello world' ); + $content = $content->addSectionHeader( 'test' ); + + $this->assertEquals( "== test ==\n\nhello world", $content->getNativeData() ); + } + + public function dataPreSaveTransform() { + return array( + array( 'hello this is ~~~', + "hello this is [[Special:Contributions/127.0.0.1|127.0.0.1]]", + ), + array( 'hello \'\'this\'\' is ~~~', + 'hello \'\'this\'\' is ~~~', + ), + ); + } + + /** + * @dataProvider dataPreSaveTransform + */ + public function testPreSaveTransform( $text, $expected ) { + global $wgUser, $wgContLang; + $options = ParserOptions::newFromUserAndLang( $wgUser, $wgContLang ); + + $content = $this->newContent( $text ); + $content = $content->preSaveTransform( $this->context->getTitle(), $this->context->getUser(), $options ); + + $this->assertEquals( $expected, $content->getNativeData() ); + } + + public function dataPreloadTransform() { + return array( + array( 'hello this is ~~~', + "hello this is ~~~", + ), + array( 'hello \'\'this\'\' is foobar', + 'hello \'\'this\'\' is bar', + ), + ); + } + + /** + * @dataProvider dataPreloadTransform + */ + public function testPreloadTransform( $text, $expected ) { + global $wgUser, $wgContLang; + $options = ParserOptions::newFromUserAndLang( $wgUser, $wgContLang ); + + $content = $this->newContent( $text ); + $content = $content->preloadTransform( $this->context->getTitle(), $options ); + + $this->assertEquals( $expected, $content->getNativeData() ); + } + + public function dataGetRedirectTarget() { + return array( + array( '#REDIRECT [[Test]]', + 'Test', + ), + array( '#REDIRECT Test', + null, + ), + array( '* #REDIRECT [[Test]]', + null, + ), + ); + } + + /** + * @dataProvider dataGetRedirectTarget + */ + public function testGetRedirectTarget( $text, $expected ) { + $content = $this->newContent( $text ); + $t = $content->getRedirectTarget( ); + + if ( is_null( $expected ) ) $this->assertNull( $t, "text should not have generated a redirect target: $text" ); + else $this->assertEquals( $expected, $t->getPrefixedText() ); + } + + /** + * @dataProvider dataGetRedirectTarget + */ + public function isRedirect( $text, $expected ) { + $content = $this->newContent( $text ); + + $this->assertEquals( !is_null($expected), $content->isRedirect() ); + } + + + /** + * @todo: test needs database! + */ + /* + public function getRedirectChain() { + $text = $this->getNativeData(); + return Title::newFromRedirectArray( $text ); + } + */ + + /** + * @todo: test needs database! + */ + /* + public function getUltimateRedirectTarget() { + $text = $this->getNativeData(); + return Title::newFromRedirectRecurse( $text ); + } + */ + + + public function dataIsCountable() { + return array( + array( '', + null, + 'any', + true + ), + array( 'Foo', + null, + 'any', + true + ), + array( 'Foo', + null, + 'comma', + false + ), + array( 'Foo, bar', + null, + 'comma', + true + ), + array( 'Foo', + null, + 'link', + false + ), + array( 'Foo [[bar]]', + null, + 'link', + true + ), + array( 'Foo', + true, + 'link', + true + ), + array( 'Foo [[bar]]', + false, + 'link', + false + ), + array( '#REDIRECT [[bar]]', + true, + 'any', + false + ), + array( '#REDIRECT [[bar]]', + true, + 'comma', + false + ), + array( '#REDIRECT [[bar]]', + true, + 'link', + false + ), + ); + } + + + /** + * @dataProvider dataIsCountable + */ + public function testIsCountable( $text, $hasLinks, $mode, $expected ) { + global $wgArticleCountMethod; + + $old = $wgArticleCountMethod; + $wgArticleCountMethod = $mode; + + $content = $this->newContent( $text ); + + $v = $content->isCountable( $hasLinks, $this->context->getTitle() ); + $wgArticleCountMethod = $old; + + $this->assertEquals( $expected, $v, "isCountable() returned unexpected value " . var_export( $v, true ) + . " instead of " . var_export( $expected, true ) . " in mode `$mode` for text \"$text\"" ); + } + + public function dataGetTextForSummary() { + return array( + array( "hello\nworld.", + 16, + 'hello world.', + ), + array( 'hello world.', + 8, + 'hello...', + ), + array( '[[hello world]].', + 8, + 'hel...', + ), + ); + } + + /** + * @dataProvider dataGetTextForSummary + */ + public function testGetTextForSummary( $text, $maxlength, $expected ) { + $content = $this->newContent( $text ); + + $this->assertEquals( $expected, $content->getTextForSummary( $maxlength ) ); + } + + + public function testGetTextForSearchIndex( ) { + $content = $this->newContent( "hello world." ); + + $this->assertEquals( "hello world.", $content->getTextForSearchIndex() ); + } + + public function testCopy() { + $content = $this->newContent( "hello world." ); + $copy = $content->copy(); + + $this->assertTrue( $content->equals( $copy ), "copy must be equal to original" ); + $this->assertEquals( "hello world.", $copy->getNativeData() ); + } + + public function testGetSize( ) { + $content = $this->newContent( "hello world." ); + + $this->assertEquals( 12, $content->getSize() ); + } + + public function testGetNativeData( ) { + $content = $this->newContent( "hello world." ); + + $this->assertEquals( "hello world.", $content->getNativeData() ); + } + + public function testGetWikitextForTransclusion( ) { + $content = $this->newContent( "hello world." ); + + $this->assertEquals( "hello world.", $content->getWikitextForTransclusion() ); + } + + # ================================================================================================================= + + public function testGetModel() { + $content = $this->newContent( "hello world." ); + + $this->assertEquals( CONTENT_MODEL_WIKITEXT, $content->getModel() ); + } + + public function testGetContentHandler() { + $content = $this->newContent( "hello world." ); + + $this->assertEquals( CONTENT_MODEL_WIKITEXT, $content->getContentHandler()->getModelID() ); + } + + public function dataIsEmpty( ) { + return array( + array( '', true ), + array( ' ', false ), + array( '0', false ), + array( 'hallo welt.', false ), + ); + } + + /** + * @dataProvider dataIsEmpty + */ + public function testIsEmpty( $text, $empty ) { + $content = $this->newContent( $text ); + + $this->assertEquals( $empty, $content->isEmpty() ); + } + + public function dataEquals( ) { + return array( + array( new WikitextContent( "hallo" ), null, false ), + array( new WikitextContent( "hallo" ), new WikitextContent( "hallo" ), true ), + array( new WikitextContent( "hallo" ), new JavascriptContent( "hallo" ), false ), + array( new WikitextContent( "hallo" ), new WikitextContent( "HALLO" ), false ), + ); + } + + /** + * @dataProvider dataEquals + */ + public function testEquals( Content $a, Content $b = null, $equal = false ) { + $this->assertEquals( $equal, $a->equals( $b ) ); + } + +} diff --git a/tests/phpunit/includes/filerepo/FileBackendTest.php b/tests/phpunit/includes/filerepo/FileBackendTest.php index 710ad83e71..88e39dffd1 100644 --- a/tests/phpunit/includes/filerepo/FileBackendTest.php +++ b/tests/phpunit/includes/filerepo/FileBackendTest.php @@ -1,6 +1,9 @@ assertNodeStart( "revision" ); $this->skipWhitespace(); @@ -311,9 +314,33 @@ abstract class DumpTestCase extends MediaWikiLangTestCase { $this->skipWhitespace(); $this->assertTextNode( "comment", $summary ); + $this->skipWhitespace(); + + if ( $this->xml->name == "text" ) { + // note: tag may occur here or at the very end. + $text_found = true; + $this->assertText( $id, $text_id, $text_bytes, $text ); + } else { + $text_found = false; + } $this->assertTextNode( "sha1", $text_sha1 ); + $this->assertTextNode( "model", $model ); + $this->skipWhitespace(); + + $this->assertTextNode( "format", $format ); + $this->skipWhitespace(); + + if ( !$text_found ) { + $this->assertText( $id, $text_id, $text_bytes, $text ); + } + + $this->assertNodeEnd( "revision" ); + $this->skipWhitespace(); + } + + protected function assertText( $id, $text_id, $text_bytes, $text ) { $this->assertNodeStart( "text", false ); if ( $text_bytes !== false ) { $this->assertEquals( $this->xml->getAttribute( "bytes" ), $text_bytes, @@ -340,9 +367,5 @@ abstract class DumpTestCase extends MediaWikiLangTestCase { $this->assertNodeEnd( "text" ); $this->skipWhitespace(); } - - $this->assertNodeEnd( "revision" ); - $this->skipWhitespace(); } - -} +} \ No newline at end of file diff --git a/tests/phpunit/maintenance/backupPrefetchTest.php b/tests/phpunit/maintenance/backupPrefetchTest.php index a64e6d0e26..87d715632d 100644 --- a/tests/phpunit/maintenance/backupPrefetchTest.php +++ b/tests/phpunit/maintenance/backupPrefetchTest.php @@ -197,6 +197,8 @@ class BaseDumpTest extends MediaWikiTestCase { BackupDumperTestP1Summary1 BackupDumperTestP1Text1 0bolhl6ol7i6x0e7yq91gxgaan39j87 + 1 + 1 '; @@ -214,6 +216,8 @@ class BaseDumpTest extends MediaWikiTestCase { BackupDumperTestP2Summary1 BackupDumperTestP2Text1 jprywrymfhysqllua29tj3sc7z39dl2 + 1 + 1 5 @@ -224,6 +228,8 @@ class BaseDumpTest extends MediaWikiTestCase { BackupDumperTestP2Summary4 extra BackupDumperTestP2Text4 some additional Text 6o1ciaxa6pybnqprmungwofc4lv00wv + 1 + 1 '; @@ -241,6 +247,8 @@ class BaseDumpTest extends MediaWikiTestCase { Talk BackupDumperTestP1 Summary1 Talk about BackupDumperTestP1 Text1 nktofwzd0tl192k3zfepmlzxoax1lpe + 1 + 1 '; diff --git a/tests/phpunit/maintenance/backupTextPassTest.php b/tests/phpunit/maintenance/backupTextPassTest.php index 90fd902745..fd43ae90a5 100644 --- a/tests/phpunit/maintenance/backupTextPassTest.php +++ b/tests/phpunit/maintenance/backupTextPassTest.php @@ -478,6 +478,8 @@ class TextPassDumperTest extends DumpTestCase { BackupDumperTestP1Summary1 0bolhl6ol7i6x0e7yq91gxgaan39j87 + 1 + 1 @@ -494,6 +496,8 @@ class TextPassDumperTest extends DumpTestCase { BackupDumperTestP2Summary1 jprywrymfhysqllua29tj3sc7z39dl2 + 1 + 1 @@ -504,6 +508,8 @@ class TextPassDumperTest extends DumpTestCase { BackupDumperTestP2Summary2 b7vj5ks32po5m1z1t1br4o7scdwwy95 + 1 + 1 @@ -514,6 +520,8 @@ class TextPassDumperTest extends DumpTestCase { BackupDumperTestP2Summary3 jfunqmh1ssfb8rs43r19w98k28gg56r + 1 + 1 @@ -524,6 +532,8 @@ class TextPassDumperTest extends DumpTestCase { BackupDumperTestP2Summary4 extra 6o1ciaxa6pybnqprmungwofc4lv00wv + 1 + 1 @@ -542,6 +552,8 @@ class TextPassDumperTest extends DumpTestCase { Talk BackupDumperTestP1 Summary1 nktofwzd0tl192k3zfepmlzxoax1lpe + 1 + 1 diff --git a/tests/phpunit/phpunit.php b/tests/phpunit/phpunit.php old mode 100755 new mode 100644 diff --git a/tests/phpunit/suite.xml b/tests/phpunit/suite.xml index f286fa1187..ddce5c5db9 100644 --- a/tests/phpunit/suite.xml +++ b/tests/phpunit/suite.xml @@ -11,7 +11,7 @@ timeoutForSmallTests="2" timeoutForMediumTests="10" timeoutForLargeTests="60" - strict="true" + strict="false" verbose="true">