EditPage to use Content objects
[lhc/web/wiklou.git] / includes / ContentHandler.php
1 <?php
2
3 /**
4 * A content handler knows how do deal with a specific type of content on a wiki page.
5 * Content is stored in the database in a serialized form (using a serialization format aka mime type)
6 * and is be unserialized into it's native PHP represenation (the content model).
7 *
8 * Some content types have a flat model, that is, their native represenation is the
9 * same as their serialized form. Examples would be JavaScript and CSS code. As of now,
10 * this also applies to wikitext (mediawiki's default content type), but wikitext
11 * content may be represented by a DOM or AST structure in the future.
12 *
13 */
14 abstract class ContentHandler {
15
16 public static function getContentText( Content $content = null ) {
17 if ( !$content ) return '';
18
19 if ( $content instanceof TextContent ) {
20 #XXX: or check by model name?
21 #XXX: or define $content->allowRawData()?
22 #XXX: or define $content->getDefaultWikiText()?
23 return $content->getNativeData();
24 }
25
26 #XXX: this must not be used for editing, otherwise we may loose data:
27 #XXX: e.g. if this returns the "main" text from a multipart page, all attachments would be lost
28
29 #TODO: log this incident!
30 return null;
31 }
32
33 public static function makeContent( $text, Title $title, $modelName = null, $format = null ) {
34 if ( !$modelName ) {
35 $modelName = $title->getContentModelName();
36 }
37
38 $handler = ContentHandler::getForModelName( $modelName );
39 return $handler->unserialize( $text, $format );
40 }
41
42 public static function getDefaultModelFor( Title $title ) {
43 global $wgNamespaceContentModels;
44
45 # NOTE: this method must not rely on $title->getContentModelName() directly or indirectly,
46 # because it is used to initialized the mContentModelName memebr.
47
48 $ns = $title->getNamespace();
49
50 $ext = false;
51 $m = null;
52 $model = null;
53
54 if ( !empty( $wgNamespaceContentModels[ $ns ] ) ) {
55 $model = $wgNamespaceContentModels[ $ns ];
56 }
57
58 # hook can determin default model
59 if ( !wfRunHooks( 'DefaultModelFor', array( $title, &$model ) ) ) { #FIXME: document new hook!
60 if ( $model ) return $model;
61 }
62
63 # Could this page contain custom CSS or JavaScript, based on the title?
64 $isCssOrJsPage = ( NS_MEDIAWIKI == $ns && preg_match( "!\.(css|js)$!u", $title->getText(), $m ) );
65 if ( $isCssOrJsPage ) $ext = $m[1];
66
67 # hook can force js/css
68 wfRunHooks( 'TitleIsCssOrJsPage', array( $title, &$isCssOrJsPage, &$ext ) ); #FIXME: add $ext to hook interface spec
69
70 # Is this a .css subpage of a user page?
71 $isJsCssSubpage = ( NS_USER == $ns && !$isCssOrJsPage && preg_match( "/\\/.*\\.(js|css)$/", $title->getText(), $m ) );
72 if ( $isJsCssSubpage ) $ext = $m[1];
73
74 # is this wikitext, according to $wgNamespaceContentModels or the DefaultModelFor hook?
75 $isWikitext = ( $model == CONTENT_MODEL_WIKITEXT || $model === null );
76 $isWikitext = ( $isWikitext && !$isCssOrJsPage && !$isJsCssSubpage );
77
78 # hook can override $isWikitext
79 wfRunHooks( 'TitleIsWikitextPage', array( $title, &$isWikitext ) );
80
81 if ( !$isWikitext ) {
82
83 if ( $ext == 'js' )
84 return CONTENT_MODEL_JAVASCRIPT;
85 else if ( $ext == 'css' )
86 return CONTENT_MODEL_CSS;
87
88 if ( $model )
89 return $model;
90 else
91 return CONTENT_MODEL_TEXT;
92 }
93
94 # we established that is must be wikitext
95 return CONTENT_MODEL_WIKITEXT;
96 }
97
98 public static function getForTitle( Title $title ) {
99 $modelName = $title->getContentModelName();
100 return ContentHandler::getForModelName( $modelName );
101 }
102
103 public static function getForContent( Content $content ) {
104 $modelName = $content->getModelName();
105 return ContentHandler::getForModelName( $modelName );
106 }
107
108 /**
109 * @static
110 * @param $modelName String the name of the content model for which to get a handler. Use CONTENT_MODEL_XXX constants.
111 * @return ContentHandler
112 * @throws MWException
113 */
114 public static function getForModelName( $modelName ) {
115 global $wgContentHandlers;
116
117 if ( empty( $wgContentHandlers[$modelName] ) ) {
118 #FIXME: hook here!
119 throw new MWException( "No handler for model $modelName registered in \$wgContentHandlers" );
120 }
121
122 if ( is_string( $wgContentHandlers[$modelName] ) ) {
123 $class = $wgContentHandlers[$modelName];
124 $wgContentHandlers[$modelName] = new $class( $modelName );
125 }
126
127 return $wgContentHandlers[$modelName];
128 }
129
130 # ----------------------------------------------------------------------------------------------------------
131 public function __construct( $modelName, $formats ) {
132 $this->mModelName = $modelName;
133 $this->mSupportedFormats = $formats;
134 }
135
136 public function getModelName() {
137 # for wikitext: wikitext; in the future: wikiast, wikidom?
138 # for wikidata: wikidata
139 return $this->mModelName;
140 }
141
142
143 public function getSupportedFormats() {
144 # for wikitext: "text/x-mediawiki-1", "text/x-mediawiki-2", etc
145 # for wikidata: "application/json", "application/x-php", etc
146 return $this->mSupportedFormats;
147 }
148
149 public function getDefaultFormat() {
150 return $this->mSupportedFormats[0];
151 }
152
153 /**
154 * @abstract
155 * @param Content $content
156 * @param null $format
157 * @return String
158 */
159 public abstract function serialize( Content $content, $format = null );
160
161 /**
162 * @abstract
163 * @param $blob String
164 * @param null $format
165 * @return Content
166 */
167 public abstract function unserialize( $blob, $format = null );
168
169 public abstract function emptyContent();
170
171 /**
172 * Return an Article object suitable for viewing the given object
173 *
174 * NOTE: does *not* do special handling for Image and Category pages!
175 * Use Article::newFromTitle() for that!
176 *
177 * @param type $title
178 * @return \Article
179 * @todo Article is being refactored into an action class, keep track of that
180 */
181 public function createArticle( Title $title ) {
182 #XXX: assert that $title->getContentModelName() == $this->getModelname()?
183 $article = new Article($title);
184 return $article;
185 }
186
187 /**
188 * Return an EditPage object suitable for editing the given object
189 *
190 * @param type $article
191 * @return \EditPage
192 */
193 public function createEditPage( Article $article ) {
194 #XXX: assert that $article->getContentObject()->getModelName() == $this->getModelname()?
195 $editPage = new EditPage( $article );
196 return $editPage;
197 }
198
199 /**
200 * Return an ExternalEdit object suitable for editing the given object
201 *
202 * @param type $article
203 * @return \ExternalEdit
204 */
205 public function createExternalEdit( IContextSource $context ) {
206 #XXX: assert that $article->getContentObject()->getModelName() == $this->getModelname()?
207 $externalEdit = new ExternalEdit( $context );
208 return $externalEdit;
209 }
210
211 /**
212 * Factory
213 * @param $context IContextSource context to use, anything else will be ignored
214 * @param $old Integer old ID we want to show and diff with.
215 * @param $new String either 'prev' or 'next'.
216 * @param $rcid Integer ??? FIXME (default 0)
217 * @param $refreshCache boolean If set, refreshes the diff cache
218 * @param $unhide boolean If set, allow viewing deleted revs
219 */
220 public function getDifferenceEngine( IContextSource $context, $old = 0, $new = 0, $rcid = 0, #FIMXE: use everywhere!
221 $refreshCache = false, $unhide = false ) {
222
223 $de = new DifferenceEngine( $context, $old, $new, $rcid, $refreshCache, $unhide );
224
225 return $de;
226 }
227
228 /**
229 * attempts to merge differences between three versions.
230 * Returns a new Content object for a clean merge and false for failure or a conflict.
231 *
232 * This default implementation always returns false.
233 *
234 * @param $oldContent String
235 * @param $myContent String
236 * @param $yourContent String
237 * @return Content|Bool
238 */
239 public function merge3( Content $oldContent, Content $myContent, Content $yourContent ) {
240 return false;
241 }
242
243 /**
244 * Return an applicable autosummary if one exists for the given edit.
245 *
246 * @param $oldContent Content: the previous text of the page.
247 * @param $newContent Content: The submitted text of the page.
248 * @param $flags Int bitmask: a bitmask of flags submitted for the edit.
249 *
250 * @return string An appropriate autosummary, or an empty string.
251 */
252 public function getAutosummary( Content $oldContent, Content $newContent, $flags ) {
253 global $wgContLang;
254
255 # Decide what kind of autosummary is needed.
256
257 # Redirect autosummaries
258 $ot = $oldContent->getRedirectTarget();
259 $rt = $newContent->getRedirectTarget();
260
261 if ( is_object( $rt ) && ( !is_object( $ot ) || !$rt->equals( $ot ) || $ot->getFragment() != $rt->getFragment() ) ) {
262
263 $truncatedtext = $newContent->getTextForSummary(
264 250
265 - strlen( wfMsgForContent( 'autoredircomment' ) )
266 - strlen( $rt->getFullText() ) );
267
268 return wfMsgForContent( 'autoredircomment', $rt->getFullText(), $truncatedtext );
269 }
270
271 # New page autosummaries
272 if ( $flags & EDIT_NEW && $newContent->getSize() > 0 ) {
273 # If they're making a new article, give its text, truncated, in the summary.
274
275 $truncatedtext = $newContent->getTextForSummary(
276 200 - strlen( wfMsgForContent( 'autosumm-new' ) ) );
277
278 return wfMsgForContent( 'autosumm-new', $truncatedtext );
279 }
280
281 # Blanking autosummaries
282 if ( $oldContent->getSize() > 0 && $newContent->getSize() == 0 ) {
283 return wfMsgForContent( 'autosumm-blank' );
284 } elseif ( $oldContent->getSize() > 10 * $newContent->getSize() && $newContent->getSize() < 500 ) {
285 # Removing more than 90% of the article
286
287 $truncatedtext = $newContent->getTextForSummary(
288 200 - strlen( wfMsgForContent( 'autosumm-replace' ) ) );
289
290 return wfMsgForContent( 'autosumm-replace', $truncatedtext );
291 }
292
293 # If we reach this point, there's no applicable autosummary for our case, so our
294 # autosummary is empty.
295 return '';
296 }
297
298 /**
299 * Auto-generates a deletion reason
300 *
301 * @param $title Title: the page's title
302 * @param &$hasHistory Boolean: whether the page has a history
303 * @return mixed String containing deletion reason or empty string, or boolean false
304 * if no revision occurred
305 */
306 public function getAutoDeleteReason( Title $title, &$hasHistory ) {
307 global $wgContLang;
308
309 $dbw = wfGetDB( DB_MASTER );
310
311 // Get the last revision
312 $rev = Revision::newFromTitle( $title );
313
314 if ( is_null( $rev ) ) {
315 return false;
316 }
317
318 // Get the article's contents
319 $content = $rev->getContent();
320 $blank = false;
321
322 // If the page is blank, use the text from the previous revision,
323 // which can only be blank if there's a move/import/protect dummy revision involved
324 if ( $content->getSize() == 0 ) {
325 $prev = $rev->getPrevious();
326
327 if ( $prev ) {
328 $content = $rev->getContent();
329 $blank = true;
330 }
331 }
332
333 // Find out if there was only one contributor
334 // Only scan the last 20 revisions
335 $res = $dbw->select( 'revision', 'rev_user_text',
336 array( 'rev_page' => $title->getArticleID(), $dbw->bitAnd( 'rev_deleted', Revision::DELETED_USER ) . ' = 0' ),
337 __METHOD__,
338 array( 'LIMIT' => 20 )
339 );
340
341 if ( $res === false ) {
342 // This page has no revisions, which is very weird
343 return false;
344 }
345
346 $hasHistory = ( $res->numRows() > 1 );
347 $row = $dbw->fetchObject( $res );
348
349 if ( $row ) { // $row is false if the only contributor is hidden
350 $onlyAuthor = $row->rev_user_text;
351 // Try to find a second contributor
352 foreach ( $res as $row ) {
353 if ( $row->rev_user_text != $onlyAuthor ) { // Bug 22999
354 $onlyAuthor = false;
355 break;
356 }
357 }
358 } else {
359 $onlyAuthor = false;
360 }
361
362 // Generate the summary with a '$1' placeholder
363 if ( $blank ) {
364 // The current revision is blank and the one before is also
365 // blank. It's just not our lucky day
366 $reason = wfMsgForContent( 'exbeforeblank', '$1' );
367 } else {
368 if ( $onlyAuthor ) {
369 $reason = wfMsgForContent( 'excontentauthor', '$1', $onlyAuthor );
370 } else {
371 $reason = wfMsgForContent( 'excontent', '$1' );
372 }
373 }
374
375 if ( $reason == '-' ) {
376 // Allow these UI messages to be blanked out cleanly
377 return '';
378 }
379
380 // Max content length = max comment length - length of the comment (excl. $1)
381 $text = $content->getTextForSummary( 255 - ( strlen( $reason ) - 2 ) );
382
383 // Now replace the '$1' placeholder
384 $reason = str_replace( '$1', $text, $reason );
385
386 return $reason;
387 }
388
389 /**
390 * Get the Content object that needs to be saved in order to undo all revisions
391 * between $undo and $undoafter. Revisions must belong to the same page,
392 * must exist and must not be deleted
393 * @param $undo Revision
394 * @param $undoafter null|Revision Must be an earlier revision than $undo
395 * @return mixed string on success, false on failure
396 */
397 public function getUndoContent( Revision $current, Revision $undo, Revision $undoafter = null ) {
398 $cur_content = $current->getContent();
399
400 if ( empty( $cur_content ) ) {
401 return false; // no page
402 }
403
404 $undo_content = $undo->getContent();
405 $undoafter_content = $undoafter->getContent();
406
407 if ( $cur_content->equals( $undo_content ) ) {
408 # No use doing a merge if it's just a straight revert.
409 return $undoafter_content;
410 }
411
412 $undone_content = $this->merge3( $undo_content, $undoafter_content, $cur_content );
413
414 return $undone_content;
415 }
416
417 #TODO: how to handle extra message for JS/CSS previews??
418 #TODO: Article::showCssOrJsPage ---> specialized classes!
419
420 #XXX: ImagePage and CategoryPage... wrappers that use ContentHandler? or ContentHandler creates wrappers?
421 }
422
423
424 abstract class TextContentHandler extends ContentHandler {
425
426 public function __construct( $modelName, $formats ) {
427 parent::__construct( $modelName, $formats );
428 }
429
430 public function serialize( Content $content, $format = null ) {
431 #FIXME: assert format
432 return $content->getNativeData();
433 }
434
435 /**
436 * attempts to merge differences between three versions.
437 * Returns a new Content object for a clean merge and false for failure or a conflict.
438 *
439 * This text-based implementation uses wfMerge().
440 *
441 * @param $oldContent String
442 * @param $myContent String
443 * @param $yourContent String
444 * @return Content|Bool
445 */
446 public function merge3( Content $oldContent, Content $myContent, Content $yourContent ) {
447 $format = $this->getDefaultFormat();
448
449 $old = $this->serialize( $oldContent, $format );
450 $mine = $this->serialize( $myContent, $format );
451 $yours = $this->serialize( $yourContent, $format );
452
453 $ok = wfMerge( $old, $mine, $yours, $result );
454
455 if ( !$ok ) return false;
456 if ( !$result ) return $this->emptyContent();
457
458 $mergedContent = $this->unserialize( $result, $format );
459 return $mergedContent;
460 }
461
462
463 }
464 class WikitextContentHandler extends TextContentHandler {
465
466 public function __construct( $modelName = CONTENT_MODEL_WIKITEXT ) {
467 parent::__construct( $modelName, array( 'application/x-wikitext' ) ); #FIXME: mime
468 }
469
470 public function unserialize( $text, $format = null ) {
471 #FIXME: assert format
472 return new WikitextContent($text);
473 }
474
475 public function emptyContent() {
476 return new WikitextContent("");
477 }
478
479
480 }
481
482 class JavaScriptContentHandler extends TextContentHandler {
483
484 public function __construct( $modelName = CONTENT_MODEL_WIKITEXT ) {
485 parent::__construct( $modelName, array( 'text/javascript' ) );
486 }
487
488 public function unserialize( $text, $format = null ) {
489 return new JavaScriptContent($text);
490 }
491
492 public function emptyContent() {
493 return new JavaScriptContent("");
494 }
495 }
496
497 class CssContentHandler extends TextContentHandler {
498
499 public function __construct( $modelName = CONTENT_MODEL_WIKITEXT ) {
500 parent::__construct( $modelName, array( 'text/css' ) );
501 }
502
503 public function unserialize( $text, $format = null ) {
504 return new CssContent($text);
505 }
506
507 public function emptyContent() {
508 return new CssContent("");
509 }
510
511 }