merged from master
[lhc/web/wiklou.git] / includes / Content.php
1 <?php
2
3 /**
4 * A content object represents page content, e.g. the text to show on a page.
5 * Content objects have no knowledge about how they relate to Wiki pages.
6 *
7 */
8 abstract class Content {
9
10 /**
11 * Name of the content model this COntent object represents.
12 * Use with CONTENT_MODEL_XXX constants
13 *
14 * @var String $model_name
15 */
16 protected $model_name;
17
18 /**
19 * @return String a string representing the content in a way useful for building a full text search index.
20 * If no useful representation exists, this method returns an empty string.
21 */
22 public abstract function getTextForSearchIndex( );
23
24 /**
25 * @return String the wikitext to include when another page includes this content, or false if the content is not
26 * includable in a wikitext page.
27 *
28 * @TODO: allow native handling, bypassing wikitext representation, like for includable special pages.
29 * @TODO: use in parser, etc!
30 */
31 public abstract function getWikitextForTransclusion( );
32
33 /**
34 * Returns a textual representation of the content suitable for use in edit summaries and log messages.
35 *
36 * @param int $maxlength maximum length of the summary text
37 * @return String the summary text
38 */
39 public abstract function getTextForSummary( $maxlength = 250 );
40
41 /**
42 * Returns native represenation of the data. Interpretation depends on the data model used,
43 * as given by getDataModel().
44 *
45 * @return mixed the native representation of the content. Could be a string, a nested array
46 * structure, an object, a binary blob... anything, really.
47 *
48 * @NOTE: review all calls carefully, caller must be aware of content model!
49 */
50 public abstract function getNativeData( );
51
52 /**
53 * returns the content's nominal size in bogo-bytes.
54 *
55 * @return int
56 */
57 public abstract function getSize( );
58
59 /**
60 * @param $model_name
61 */
62 public function __construct( $model_name = null ) {
63 $this->model_name = $model_name;
64 }
65
66 /**
67 * Returns the name of the content model used by this content objects.
68 * Corresponds to the CONTENT_MODEL_XXX constants.
69 *
70 * @return String the model name
71 */
72 public function getModelName() {
73 return $this->model_name;
74 }
75
76 /**
77 * Throws an MWException if $model_name is not the name of the content model
78 * supported by this Content object.
79 *
80 * @param String $model_name the model to check
81 */
82 protected function checkModelName( $model_name ) {
83 if ( $model_name !== $this->model_name ) {
84 throw new MWException( "Bad content model: expected " . $this->model_name . " but got found " . $model_name );
85 }
86 }
87
88 /**
89 * Conveniance method that returns the ContentHandler singleton for handling the content
90 * model this Content object uses.
91 *
92 * Shorthand for ContentHandler::getForContent( $this )
93 *
94 * @return ContentHandler
95 */
96 public function getContentHandler() {
97 return ContentHandler::getForContent( $this );
98 }
99
100 /**
101 * Conveniance method that returns the default serialization format for the content model
102 * model this Content object uses.
103 *
104 * Shorthand for $this->getContentHandler()->getDefaultFormat()
105 *
106 * @return ContentHandler
107 */
108 public function getDefaultFormat() {
109 return $this->getContentHandler()->getDefaultFormat();
110 }
111
112 /**
113 * Conveniance method that returns the list of serialization formats supported
114 * for the content model model this Content object uses.
115 *
116 * Shorthand for $this->getContentHandler()->getSupportedFormats()
117 *
118 * @return array of supported serialization formats
119 */
120 public function getSupportedFormats() {
121 return $this->getContentHandler()->getSupportedFormats();
122 }
123
124 /**
125 * Returns true if $format is a supported serialization format for this Content object,
126 * false if it isn't.
127 *
128 * Note that this will always return true if $format is null, because null stands for the
129 * default serialization.
130 *
131 * Shorthand for $this->getContentHandler()->isSupportedFormat( $format )
132 *
133 * @param String $format the format to check
134 * @return bool whether the format is supported
135 */
136 public function isSupportedFormat( $format ) {
137 if ( !$format ) {
138 return true; // this means "use the default"
139 }
140
141 return $this->getContentHandler()->isSupportedFormat( $format );
142 }
143
144 /**
145 * Throws an MWException if $this->isSupportedFormat( $format ) doesn't return true.
146 *
147 * @param $format
148 * @throws MWException
149 */
150 protected function checkFormat( $format ) {
151 if ( !$this->isSupportedFormat( $format ) ) {
152 throw new MWException( "Format $format is not supported for content model " . $this->getModelName() );
153 }
154 }
155
156 /**
157 * Conveniance method for serializing this Content object.
158 *
159 * Shorthand for $this->getContentHandler()->serializeContent( $this, $format )
160 *
161 * @param null|String $format the desired serialization format (or null for the default format).
162 * @return String serialized form of this Content object
163 */
164 public function serialize( $format = null ) {
165 return $this->getContentHandler()->serializeContent( $this, $format );
166 }
167
168 /**
169 * Returns true if this Content object represents empty content.
170 *
171 * @return bool whether this Content object is empty
172 */
173 public function isEmpty() {
174 return $this->getSize() == 0;
175 }
176
177 /**
178 * Returns true if this Content objects is conceptually equivalent to the given Content object.
179 *
180 * Will returns false if $that is null.
181 * Will return true if $that === $this.
182 * Will return false if $that->getModleName() != $this->getModelName().
183 * Will return false if $that->getNativeData() is not equal to $this->getNativeData(),
184 * where the meaning of "equal" depends on the actual data model.
185 *
186 * Implementations should be careful to make equals() transitive and reflexive:
187 *
188 * * $a->equals( $b ) <=> $b->equals( $b )
189 * * $a->equals( $b ) && $b->equals( $c ) ==> $a->equals( $c )
190 *
191 * @param Content $that the Content object to compare to
192 * @return bool true if this Content object is euqual to $that, false otherwise.
193 */
194 public function equals( Content $that = null ) {
195 if ( is_null( $that ) ){
196 return false;
197 }
198
199 if ( $that === $this ) {
200 return true;
201 }
202
203 if ( $that->getModelName() !== $this->getModelName() ) {
204 return false;
205 }
206
207 return $this->getNativeData() === $that->getNativeData();
208 }
209
210 /**
211 * Return a copy of this Content object. The following must be true for the object returned
212 * if $copy = $original->copy()
213 *
214 * * get_class($original) === get_class($copy)
215 * * $original->getModelName() === $copy->getModelName()
216 * * $original->equals( $copy )
217 *
218 * If and only if the Content object is imutable, the copy() method can and should
219 * return $this. That is, $copy === $original may be true, but only for imutable content
220 * objects.
221 *
222 * @return Content. A copy of this object
223 */
224 public abstract function copy( );
225
226 /**
227 * Returns true if this content is countable as a "real" wiki page, provided
228 * that it's also in a countable location (e.g. a current revision in the main namespace).
229 *
230 * @param $hasLinks Bool: if it is known whether this content contains links, provide this information here,
231 * to avoid redundant parsing to find out.
232 * @return boolean
233 */
234 public abstract function isCountable( $hasLinks = null ) ;
235
236 /**
237 * @param IContextSource $context
238 * @param null $revId
239 * @param null|ParserOptions $options
240 * @param Boolean $generateHtml whether to generate Html (default: true). If false,
241 * the result of calling getText() on the ParserOutput object returned by
242 * this method is undefined.
243 *
244 * @return ParserOutput
245 */
246 public abstract function getParserOutput( IContextSource $context, $revId = null, ParserOptions $options = NULL, $generateHtml = true );
247
248 /**
249 * Construct the redirect destination from this content and return an
250 * array of Titles, or null if this content doesn't represent a redirect.
251 * The last element in the array is the final destination after all redirects
252 * have been resolved (up to $wgMaxRedirects times).
253 *
254 * @return Array of Titles, with the destination last
255 */
256 public function getRedirectChain() {
257 return null;
258 }
259
260 /**
261 * Construct the redirect destination from this content and return an
262 * array of Titles, or null if this content doesn't represent a redirect.
263 * This will only return the immediate redirect target, useful for
264 * the redirect table and other checks that don't need full recursion.
265 *
266 * @return Title: The corresponding Title
267 */
268 public function getRedirectTarget() {
269 return null;
270 }
271
272 /**
273 * Construct the redirect destination from this content and return the
274 * Title, or null if this content doesn't represent a redirect.
275 * This will recurse down $wgMaxRedirects times or until a non-redirect target is hit
276 * in order to provide (hopefully) the Title of the final destination instead of another redirect.
277 *
278 * @return Title
279 */
280 public function getUltimateRedirectTarget() {
281 return null;
282 }
283
284 public function isRedirect() {
285 return $this->getRedirectTarget() != null;
286 }
287
288 /**
289 * Returns the section with the given id.
290 *
291 * The default implementation returns null.
292 *
293 * @param String $sectionId the section's id, given as a numeric string. The id "0" retrieves the section before
294 * the first heading, "1" the text between the first heading (inluded) and the second heading (excluded), etc.
295 * @return Content|Boolean|null the section, or false if no such section exist, or null if sections are not supported
296 */
297 public function getSection( $sectionId ) {
298 return null;
299 }
300
301 /**
302 * Replaces a section of the content and returns a Content object with the section replaced.
303 *
304 * @param $section empty/null/false or a section number (0, 1, 2, T1, T2...), or "new"
305 * @param $with Content: new content of the section
306 * @param $sectionTitle String: new section's subject, only if $section is 'new'
307 * @return string Complete article text, or null if error
308 */
309 public function replaceSection( $section, Content $with, $sectionTitle = '' ) {
310 return null;
311 }
312
313 /**
314 * Returns a Content object with pre-save transformations applied (or this object if no transformations apply).
315 *
316 * @param Title $title
317 * @param User $user
318 * @param null|ParserOptions $popts
319 * @return Content
320 */
321 public function preSaveTransform( Title $title, User $user, ParserOptions $popts = null ) {
322 return $this;
323 }
324
325 /**
326 * Returns a new WikitextContent object with the given section heading prepended, if supported.
327 * The default implementation just returns this Content object unmodified, ignoring the section header.
328 *
329 * @param $header String
330 * @return Content
331 */
332 public function addSectionHeader( $header ) {
333 return $this;
334 }
335
336 /**
337 * Returns a Content object with preload transformations applied (or this object if no transformations apply).
338 *
339 * @param Title $title
340 * @param null|ParserOptions $popts
341 * @return Content
342 */
343 public function preloadTransform( Title $title, ParserOptions $popts = null ) {
344 return $this;
345 }
346
347 # TODO: handle ImagePage and CategoryPage
348 # TODO: make sure we cover lucene search / wikisearch.
349 # TODO: make sure ReplaceTemplates still works
350 # FUTURE: nice&sane integration of GeSHi syntax highlighting
351 # [11:59] <vvv> Hooks are ugly; make CodeHighlighter interface and a config to set the class which handles syntax highlighting
352 # [12:00] <vvv> And default it to a DummyHighlighter
353
354 # TODO: make sure we cover the external editor interface (does anyone actually use that?!)
355
356 # TODO: tie into API to provide contentModel for Revisions
357 # TODO: tie into API to provide serialized version and contentFormat for Revisions
358 # TODO: tie into API edit interface
359 # FUTURE: make EditForm plugin for EditPage
360 }
361 # FUTURE: special type for redirects?!
362 # FUTURE: MultipartMultipart < WikipageContent (Main + Links + X)
363 # FUTURE: LinksContent < LanguageLinksContent, CategoriesContent
364
365 /**
366 * Content object implementation for representing flat text.
367 *
368 * TextContent instances are imutable
369 */
370 abstract class TextContent extends Content {
371
372 public function __construct( $text, $model_name = null ) {
373 parent::__construct( $model_name );
374
375 $this->mText = $text;
376 }
377
378 public function copy() {
379 return $this; #NOTE: this is ok since TextContent are imutable.
380 }
381
382 public function getTextForSummary( $maxlength = 250 ) {
383 global $wgContLang;
384
385 $text = $this->getNativeData();
386
387 $truncatedtext = $wgContLang->truncate(
388 preg_replace( "/[\n\r]/", ' ', $text ),
389 max( 0, $maxlength ) );
390
391 return $truncatedtext;
392 }
393
394 /**
395 * returns the text's size in bytes.
396 *
397 * @return int the size
398 */
399 public function getSize( ) {
400 $text = $this->getNativeData( );
401 return strlen( $text );
402 }
403
404 /**
405 * Returns true if this content is not a redirect, and $wgArticleCountMethod is "any".
406 *
407 * @param $hasLinks Bool: if it is known whether this content contains links, provide this information here,
408 * to avoid redundant parsing to find out.
409 *
410 * @return bool true if the content is countable
411 */
412 public function isCountable( $hasLinks = null ) {
413 global $wgArticleCountMethod;
414
415 if ( $this->isRedirect( ) ) {
416 return false;
417 }
418
419 if ( $wgArticleCountMethod === 'any' ) {
420 return true;
421 }
422
423 return false;
424 }
425
426 /**
427 * Returns the text represented by this Content object, as a string.
428 *
429 * @return String the raw text
430 */
431 public function getNativeData( ) {
432 $text = $this->mText;
433 return $text;
434 }
435
436 /**
437 * Returns the text represented by this Content object, as a string.
438 *
439 * @return String the raw text
440 */
441 public function getTextForSearchIndex( ) {
442 return $this->getNativeData();
443 }
444
445 /**
446 * Returns the text represented by this Content object, as a string.
447 *
448 * @return String the raw text
449 */
450 public function getWikitextForTransclusion( ) {
451 return $this->getNativeData();
452 }
453
454 /**
455 * Returns a generic ParserOutput object, wrapping the HTML returned by getHtml().
456 *
457 * @return ParserOutput representing the HTML form of the text
458 */
459 public function getParserOutput( IContextSource $context, $revId = null, ParserOptions $options = null, $generateHtml = true ) {
460 # generic implementation, relying on $this->getHtml()
461
462 if ( $generateHtml ) $html = $this->getHtml( $options );
463 else $html = '';
464
465 $po = new ParserOutput( $html );
466
467 return $po;
468 }
469
470 protected abstract function getHtml( );
471
472 }
473
474 class WikitextContent extends TextContent {
475
476 public function __construct( $text ) {
477 parent::__construct($text, CONTENT_MODEL_WIKITEXT);
478
479 $this->mDefaultParserOptions = null; #TODO: use per-class static member?!
480 }
481
482 protected function getHtml( ) {
483 throw new MWException( "getHtml() not implemented for wikitext. Use getParserOutput()->getText()." );
484 }
485
486 public function getDefaultParserOptions() {
487 global $wgUser, $wgContLang;
488
489 if ( !$this->mDefaultParserOptions ) { #TODO: use per-class static member?!
490 $this->mDefaultParserOptions = ParserOptions::newFromUserAndLang( $wgUser, $wgContLang );
491 }
492
493 return $this->mDefaultParserOptions;
494 }
495
496 /**
497 * Returns a ParserOutput object resulting from parsing the content's text using $wgParser.
498 *
499 * @since WikiData1
500 *
501 * @param IContextSource|null $context
502 * @param null $revId
503 * @param null|ParserOptions $options
504 * @param bool $generateHtml
505 *
506 * @return ParserOutput representing the HTML form of the text
507 */
508 public function getParserOutput( IContextSource $context, $revId = null, ParserOptions $options = null, $generateHtml = true ) {
509 global $wgParser;
510
511 if ( !$options ) {
512 $options = $this->getDefaultParserOptions();
513 }
514
515 $po = $wgParser->parse( $this->mText, $context->getTitle(), $options, true, true, $revId );
516
517 return $po;
518 }
519
520 /**
521 * Returns the section with the given id.
522 *
523 * @param String $sectionId the section's id
524 * @return Content|false|null the section, or false if no such section exist, or null if sections are not supported
525 */
526 public function getSection( $section ) {
527 global $wgParser;
528
529 $text = $this->getNativeData();
530 $sect = $wgParser->getSection( $text, $section, false );
531
532 return new WikitextContent( $sect );
533 }
534
535 /**
536 * Replaces a section in the wikitext
537 *
538 * @param $section empty/null/false or a section number (0, 1, 2, T1, T2...), or "new"
539 * @param $with Content: new content of the section
540 * @param $sectionTitle String: new section's subject, only if $section is 'new'
541 * @return Content Complete article content, or null if error
542 */
543 public function replaceSection( $section, Content $with, $sectionTitle = '' ) {
544 wfProfileIn( __METHOD__ );
545
546 $myModelName = $this->getModelName();
547 $sectionModelName = $with->getModelName();
548
549 if ( $sectionModelName != $myModelName ) {
550 throw new MWException( "Incompatible content model for section: document uses $myModelName, section uses $sectionModelName." );
551 }
552
553 $oldtext = $this->getNativeData();
554 $text = $with->getNativeData();
555
556 if ( $section === '' ) {
557 return $with; #XXX: copy first?
558 } if ( $section == 'new' ) {
559 # Inserting a new section
560 $subject = $sectionTitle ? wfMsgForContent( 'newsectionheaderdefaultlevel', $sectionTitle ) . "\n\n" : '';
561 if ( wfRunHooks( 'PlaceNewSection', array( $this, $oldtext, $subject, &$text ) ) ) {
562 $text = strlen( trim( $oldtext ) ) > 0
563 ? "{$oldtext}\n\n{$subject}{$text}"
564 : "{$subject}{$text}";
565 }
566 } else {
567 # Replacing an existing section; roll out the big guns
568 global $wgParser;
569
570 $text = $wgParser->replaceSection( $oldtext, $section, $text );
571 }
572
573 $newContent = new WikitextContent( $text );
574
575 wfProfileOut( __METHOD__ );
576 return $newContent;
577 }
578
579 /**
580 * Returns a new WikitextContent object with the given section heading prepended.
581 *
582 * @param $header String
583 * @return Content
584 */
585 public function addSectionHeader( $header ) {
586 $text = wfMsgForContent( 'newsectionheaderdefaultlevel', $header ) . "\n\n" . $this->getNativeData();
587
588 return new WikitextContent( $text );
589 }
590
591 /**
592 * Returns a Content object with pre-save transformations applied (or this object if no transformations apply).
593 *
594 * @param Title $title
595 * @param User $user
596 * @param null|ParserOptions $popts
597 * @return Content
598 */
599 public function preSaveTransform( Title $title, User $user, ParserOptions $popts = null ) {
600 global $wgParser;
601
602 if ( $popts == null ) $popts = $this->getDefaultParserOptions();
603
604 $text = $this->getNativeData();
605 $pst = $wgParser->preSaveTransform( $text, $title, $user, $popts );
606
607 return new WikitextContent( $pst );
608 }
609
610 /**
611 * Returns a Content object with preload transformations applied (or this object if no transformations apply).
612 *
613 * @param Title $title
614 * @param null|ParserOptions $popts
615 * @return Content
616 */
617 public function preloadTransform( Title $title, ParserOptions $popts = null ) {
618 global $wgParser;
619
620 if ( $popts == null ) $popts = $this->getDefaultParserOptions();
621
622 $text = $this->getNativeData();
623 $plt = $wgParser->getPreloadText( $text, $title, $popts );
624
625 return new WikitextContent( $plt );
626 }
627
628 public function getRedirectChain() {
629 $text = $this->getNativeData();
630 return Title::newFromRedirectArray( $text );
631 }
632
633 public function getRedirectTarget() {
634 $text = $this->getNativeData();
635 return Title::newFromRedirect( $text );
636 }
637
638 public function getUltimateRedirectTarget() {
639 $text = $this->getNativeData();
640 return Title::newFromRedirectRecurse( $text );
641 }
642
643 /**
644 * Returns true if this content is not a redirect, and this content's text is countable according to
645 * the criteria defiend by $wgArticleCountMethod.
646 *
647 * @param Bool $hasLinks if it is known whether this content contains links, provide this information here,
648 * to avoid redundant parsing to find out.
649 * @param IContextSource $context context for parsing if necessary
650 *
651 * @return bool true if the content is countable
652 */
653 public function isCountable( $hasLinks = null, IContextSource $context = null ) {
654 global $wgArticleCountMethod, $wgRequest;
655
656 if ( $this->isRedirect( ) ) {
657 return false;
658 }
659
660 $text = $this->getNativeData();
661
662 switch ( $wgArticleCountMethod ) {
663 case 'any':
664 return true;
665 case 'comma':
666 return strpos( $text, ',' ) !== false;
667 case 'link':
668 if ( $hasLinks === null ) { # not known, find out
669 if ( !$context ) { # make dummy context
670 //XXX: caller of this method often knows the title, but not a context...
671 $context = new RequestContext( $wgRequest );
672 }
673
674 $po = $this->getParserOutput( $context, null, null, false );
675 $links = $po->getLinks();
676 $hasLinks = !empty( $links );
677 }
678
679 return $hasLinks;
680 }
681 }
682
683 public function getTextForSummary( $maxlength = 250 ) {
684 $truncatedtext = parent::getTextForSummary( $maxlength );
685
686 #clean up unfinished links
687 #XXX: make this optional? wasn't there in autosummary, but required for deletion summary.
688 $truncatedtext = preg_replace( '/\[\[([^\]]*)\]?$/', '$1', $truncatedtext );
689
690 return $truncatedtext;
691 }
692
693 }
694
695 class MessageContent extends TextContent {
696 public function __construct( $msg_key, $params = null, $options = null ) {
697 parent::__construct(null, CONTENT_MODEL_WIKITEXT); #XXX: messages may be wikitext, html or plain text! and maybe even something else entirely.
698
699 $this->mMessageKey = $msg_key;
700
701 $this->mParameters = $params;
702
703 if ( is_null( $options ) ) {
704 $options = array();
705 }
706 elseif ( is_string( $options ) ) {
707 $options = array( $options );
708 }
709
710 $this->mOptions = $options;
711
712 $this->mHtmlOptions = null;
713 }
714
715 /**
716 * Returns the message as rendered HTML, using the options supplied to the constructor plus "parse".
717 */
718 protected function getHtml( ) {
719 $opt = array_merge( $this->mOptions, array('parse') );
720
721 return wfMsgExt( $this->mMessageKey, $this->mParameters, $opt );
722 }
723
724
725 /**
726 * Returns the message as raw text, using the options supplied to the constructor minus "parse" and "parseinline".
727 */
728 public function getNativeData( ) {
729 $opt = array_diff( $this->mOptions, array('parse', 'parseinline') );
730
731 return wfMsgExt( $this->mMessageKey, $this->mParameters, $opt );
732 }
733
734 }
735
736
737 class JavaScriptContent extends TextContent {
738 public function __construct( $text ) {
739 parent::__construct($text, CONTENT_MODEL_JAVASCRIPT);
740 }
741
742 protected function getHtml( ) {
743 $html = "";
744 $html .= "<pre class=\"mw-code mw-js\" dir=\"ltr\">\n";
745 $html .= htmlspecialchars( $this->getNativeData() );
746 $html .= "\n</pre>\n";
747
748 return $html;
749 }
750
751 }
752
753 class CssContent extends TextContent {
754 public function __construct( $text ) {
755 parent::__construct($text, CONTENT_MODEL_CSS);
756 }
757
758 protected function getHtml( ) {
759 $html = "";
760 $html .= "<pre class=\"mw-code mw-css\" dir=\"ltr\">\n";
761 $html .= htmlspecialchars( $this->getNativeData() );
762 $html .= "\n</pre>\n";
763
764 return $html;
765 }
766 }