Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
34.34% |
68 / 198 |
|
30.00% |
3 / 10 |
CRAP | |
0.00% |
0 / 1 |
CargoQueryDisplayer | |
34.34% |
68 / 198 |
|
30.00% |
3 / 10 |
2279.79 | |
0.00% |
0 / 1 |
newFromSQLQuery | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
1 | |||
getAllFormatClasses | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
getFormatClass | |
71.43% |
5 / 7 |
|
0.00% |
0 / 1 |
3.21 | |||
getFormatter | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
getFormattedQueryResults | |
52.94% |
27 / 51 |
|
0.00% |
0 / 1 |
116.64 | |||
formatFieldValue | |
27.27% |
15 / 55 |
|
0.00% |
0 / 1 |
226.49 | |||
formatDateFieldValue | |
0.00% |
0 / 19 |
|
0.00% |
0 / 1 |
72 | |||
getTextSnippet | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
20 | |||
displayQueryResults | |
66.67% |
10 / 15 |
|
0.00% |
0 / 1 |
10.37 | |||
viewMoreResultsLink | |
0.00% |
0 / 30 |
|
0.00% |
0 / 1 |
110 |
1 | <?php |
2 | |
3 | use MediaWiki\Html\Html; |
4 | use MediaWiki\Linker\Linker; |
5 | use MediaWiki\MediaWikiServices; |
6 | use MediaWiki\Title\Title; |
7 | |
8 | /** |
9 | * CargoQueryDisplayer - class for displaying query results. |
10 | * |
11 | * @author Yaron Koren |
12 | * @ingroup Cargo |
13 | */ |
14 | |
15 | class CargoQueryDisplayer { |
16 | |
17 | private const FORMAT_CLASSES = [ |
18 | 'list' => CargoListFormat::class, |
19 | 'ul' => CargoULFormat::class, |
20 | 'ol' => CargoOLFormat::class, |
21 | 'template' => CargoTemplateFormat::class, |
22 | 'embedded' => CargoEmbeddedFormat::class, |
23 | 'csv' => CargoCSVFormat::class, |
24 | 'excel' => CargoExcelFormat::class, |
25 | 'feed' => CargoFeedFormat::class, |
26 | 'json' => CargoJSONFormat::class, |
27 | 'outline' => CargoOutlineFormat::class, |
28 | 'tree' => CargoTreeFormat::class, |
29 | 'table' => CargoTableFormat::class, |
30 | 'dynamic table' => CargoDynamicTableFormat::class, |
31 | 'map' => CargoMapsFormat::class, |
32 | 'googlemaps' => CargoGoogleMapsFormat::class, |
33 | 'leaflet' => CargoLeafletFormat::class, |
34 | 'openlayers' => CargoOpenLayersFormat::class, |
35 | 'calendar' => CargoCalendarFormat::class, |
36 | 'icalendar' => CargoICalendarFormat::class, |
37 | 'timeline' => CargoTimelineFormat::class, |
38 | 'gantt' => CargoGanttFormat::class, |
39 | 'bpmn' => CargoBPMNFormat::class, |
40 | 'category' => CargoCategoryFormat::class, |
41 | 'bar chart' => CargoBarChartFormat::class, |
42 | 'pie chart' => CargoPieChartFormat::class, |
43 | 'gallery' => CargoGalleryFormat::class, |
44 | 'slideshow' => CargoSlideshowFormat::class, |
45 | 'tag cloud' => CargoTagCloudFormat::class, |
46 | 'exhibit' => CargoExhibitFormat::class, |
47 | 'bibtex' => CargoBibtexFormat::class, |
48 | 'zip' => CargoZipFormat::class, |
49 | ]; |
50 | |
51 | public $mSQLQuery; |
52 | public $mFormat; |
53 | public $mDisplayParams = []; |
54 | public $mParser = null; |
55 | public $mFieldDescriptions = []; |
56 | public $mFieldTables; |
57 | |
58 | public static function newFromSQLQuery( $sqlQuery ) { |
59 | $cqd = new CargoQueryDisplayer(); |
60 | $cqd->mSQLQuery = $sqlQuery; |
61 | $cqd->mFieldDescriptions = $sqlQuery->mFieldDescriptions; |
62 | $cqd->mFieldTables = $sqlQuery->mFieldTables; |
63 | return $cqd; |
64 | } |
65 | |
66 | /** |
67 | * @return string[] List of {@see CargoDisplayFormat} subclasses |
68 | */ |
69 | public static function getAllFormatClasses() { |
70 | $formatClasses = self::FORMAT_CLASSES; |
71 | |
72 | // Let other extensions add their own formats - or even |
73 | // remove formats, if they want to. |
74 | MediaWikiServices::getInstance()->getHookContainer()->run( 'CargoSetFormatClasses', [ &$formatClasses ] ); |
75 | |
76 | return $formatClasses; |
77 | } |
78 | |
79 | /** |
80 | * Given a format name, and a list of the fields, returns the name |
81 | * of the class to instantiate for that format. |
82 | * @return string |
83 | */ |
84 | public function getFormatClass() { |
85 | $formatClasses = self::getAllFormatClasses(); |
86 | if ( array_key_exists( $this->mFormat, $formatClasses ) ) { |
87 | return $formatClasses[$this->mFormat]; |
88 | } |
89 | |
90 | if ( count( $this->mFieldDescriptions ) > 1 ) { |
91 | $format = 'table'; |
92 | } else { |
93 | $format = 'list'; |
94 | } |
95 | return $formatClasses[$format]; |
96 | } |
97 | |
98 | /** |
99 | * @param ParserOutput $out |
100 | * @param Parser|null $parser |
101 | * @return CargoDisplayFormat |
102 | */ |
103 | public function getFormatter( $out, $parser = null ) { |
104 | $formatClass = $this->getFormatClass(); |
105 | $formatObject = new $formatClass( $out, $parser ); |
106 | return $formatObject; |
107 | } |
108 | |
109 | public function getFormattedQueryResults( $queryResults, $escapeValues = false ) { |
110 | global $wgScriptPath, $wgServer; |
111 | |
112 | // The assignment will do a copy. |
113 | $formattedQueryResults = $queryResults; |
114 | foreach ( $queryResults as $rowNum => $row ) { |
115 | foreach ( $row as $fieldName => $value ) { |
116 | if ( $value === null || trim( $value ) === '' ) { |
117 | continue; |
118 | } |
119 | |
120 | if ( !array_key_exists( $fieldName, $this->mFieldDescriptions ) ) { |
121 | continue; |
122 | } |
123 | |
124 | $fieldDescription = $this->mFieldDescriptions[$fieldName]; |
125 | if ( is_array( $this->mFieldTables ) && array_key_exists( $fieldName, $this->mFieldTables ) ) { |
126 | $fieldTableName = $this->mFieldTables[$fieldName]; |
127 | } |
128 | $fieldType = $fieldDescription->mType; |
129 | |
130 | $text = ''; |
131 | if ( $fieldDescription->mIsList ) { |
132 | // There's probably an easier way to do |
133 | // this, using array_map(). |
134 | $delimiter = $fieldDescription->getDelimiter(); |
135 | // We need to decode it in case the delimiter is ; |
136 | $valueDecoded = html_entity_decode( $value ); |
137 | $fieldValues = explode( $delimiter, $valueDecoded ); |
138 | foreach ( $fieldValues as $i => $fieldValue ) { |
139 | if ( trim( $fieldValue ) == '' ) { |
140 | continue; |
141 | } |
142 | if ( $i > 0 ) { |
143 | // Use a bullet point as |
144 | // the list delimiter - |
145 | // it's better than using |
146 | // a comma, or the |
147 | // defined delimiter, |
148 | // because it's more |
149 | // consistent and makes |
150 | // it clearer whether |
151 | // list parsing worked. |
152 | $text .= ' <span class="CargoDelimiter">•</span> '; |
153 | } |
154 | $text .= self::formatFieldValue( $fieldValue, $fieldType, $fieldDescription, $this->mParser, $escapeValues ); |
155 | } |
156 | } elseif ( $fieldDescription->isDateOrDatetime() ) { |
157 | $datePrecisionField = $fieldName . '__precision'; |
158 | if ( $fieldName[0] == '_' ) { |
159 | // Special handling for pre-specified fields. |
160 | $datePrecision = ( $fieldType == 'Datetime' ) ? CargoStore::DATE_AND_TIME : CargoStore::DATE_ONLY; |
161 | } elseif ( array_key_exists( $datePrecisionField, $row ) ) { |
162 | $datePrecision = $row[$datePrecisionField]; |
163 | } else { |
164 | $fullDatePrecisionField = $fieldTableName . '.' . $datePrecisionField; |
165 | if ( array_key_exists( $fullDatePrecisionField, $row ) ) { |
166 | $datePrecision = $row[$fullDatePrecisionField]; |
167 | } else { |
168 | // This should never |
169 | // happen, but if it |
170 | // does - let's just |
171 | // give up. |
172 | $datePrecision = CargoStore::DATE_ONLY; |
173 | } |
174 | } |
175 | $text = self::formatDateFieldValue( $value, $datePrecision, $fieldType ); |
176 | } elseif ( $fieldType == 'Boolean' ) { |
177 | // Displaying a check mark for "yes" |
178 | // and an x mark for "no" would be |
179 | // cool, but those are apparently far |
180 | // from universal symbols. |
181 | $text = ( $value == true ) ? wfMessage( 'htmlform-yes' )->escaped() : wfMessage( 'htmlform-no' )->escaped(); |
182 | } elseif ( $fieldType == 'Searchtext' && $this->mSQLQuery && array_key_exists( $fieldName, $this->mSQLQuery->mSearchTerms ) ) { |
183 | $searchTerms = $this->mSQLQuery->mSearchTerms[$fieldName]; |
184 | $text = Html::rawElement( 'span', [ 'class' => 'searchresult' ], self::getTextSnippet( $value, $searchTerms ) ); |
185 | } elseif ( $fieldType == 'Rating' ) { |
186 | $rate = $value * 20; |
187 | $url = $wgServer . $wgScriptPath . '/' . 'extensions/Cargo/resources/images/star-rating-sprite-1.png'; |
188 | $text = '<span style="display: block; width: 65px; height: 13px; background: url(\'' . $url . '\') 0 0;"> |
189 | <span style="display: block; width: ' . $rate . '%; height: 13px; background: url(\'' . $url . '\') 0 -13px;"></span>'; |
190 | } else { |
191 | $text = self::formatFieldValue( $value, $fieldType, $fieldDescription, $this->mParser, $escapeValues ); |
192 | } |
193 | |
194 | if ( array_key_exists( 'max display chars', $this->mDisplayParams ) && ( $fieldType == 'Text' || $fieldType == 'Wikitext' ) ) { |
195 | $maxDisplayChars = $this->mDisplayParams['max display chars']; |
196 | if ( strlen( $text ) > $maxDisplayChars && strlen( strip_tags( $text ) ) > $maxDisplayChars ) { |
197 | $text = '<span class="cargoMinimizedText">' . $text . '</span>'; |
198 | } |
199 | } |
200 | |
201 | if ( $text != '' ) { |
202 | $formattedQueryResults[$rowNum][$fieldName] = $text; |
203 | } |
204 | } |
205 | } |
206 | return $formattedQueryResults; |
207 | } |
208 | |
209 | public static function formatFieldValue( $value, $type, $fieldDescription, $parser, $escapeValue ) { |
210 | if ( $type == 'Integer' ) { |
211 | global $wgCargoDecimalMark, $wgCargoDigitGroupingCharacter; |
212 | return number_format( $value, 0, $wgCargoDecimalMark, $wgCargoDigitGroupingCharacter ); |
213 | } elseif ( $type == 'Float' ) { |
214 | global $wgCargoDecimalMark, $wgCargoDigitGroupingCharacter; |
215 | // Can we assume that the decimal mark will be a '.' in the database? |
216 | $locOfDecimalPoint = strrpos( $value, '.' ); |
217 | if ( $locOfDecimalPoint === false ) { |
218 | // Better to show "17.0" than "17", if it's a Float. |
219 | $numDecimalPlaces = 1; |
220 | } else { |
221 | $numDecimalPlaces = strlen( $value ) - $locOfDecimalPoint - 1; |
222 | } |
223 | return number_format( $value, $numDecimalPlaces, $wgCargoDecimalMark, |
224 | $wgCargoDigitGroupingCharacter ); |
225 | } elseif ( $type == 'Page' ) { |
226 | $title = Title::newFromText( $value ); |
227 | if ( $title == null ) { |
228 | return null; |
229 | } |
230 | $linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer(); |
231 | // Hide the namespace in the display? |
232 | global $wgCargoHideNamespaceName; |
233 | if ( in_array( $title->getNamespace(), $wgCargoHideNamespaceName ) ) { |
234 | return CargoUtils::makeLink( $linkRenderer, $title, htmlspecialchars( $title->getRootText() ) ); |
235 | } else { |
236 | return CargoUtils::makeLink( $linkRenderer, $title ); |
237 | } |
238 | } elseif ( $type == 'File' ) { |
239 | // 'File' values are basically pages in the File: |
240 | // namespace; they are displayed as thumbnails within |
241 | // queries. |
242 | $title = Title::newFromText( $value, NS_FILE ); |
243 | if ( !$title ) { |
244 | return null; |
245 | } |
246 | |
247 | $file = MediaWikiServices::getInstance()->getRepoGroup()->findFile( $title ); |
248 | return Linker::makeThumbLinkObj( |
249 | $title, |
250 | $file, |
251 | $value, |
252 | '' |
253 | ); |
254 | } elseif ( $type == 'URL' ) { |
255 | // Validate URL - regexp code copied from Sanitizer::validateAttributes(). |
256 | $urlUtils = MediaWikiServices::getInstance()->getUrlUtils(); |
257 | $hrefExp = '/^(' . $urlUtils->validProtocols() . ')[^\s]+$/'; |
258 | if ( !preg_match( $hrefExp, $value ) ) { |
259 | if ( $escapeValue ) { |
260 | return htmlspecialchars( $value ); |
261 | } else { |
262 | return $value; |
263 | } |
264 | } elseif ( array_key_exists( 'link text', $fieldDescription->mOtherParams ) ) { |
265 | return Html::element( 'a', [ 'href' => $value ], |
266 | $fieldDescription->mOtherParams['link text'] ); |
267 | } else { |
268 | // Otherwise, display the URL as a link. |
269 | global $wgNoFollowLinks; |
270 | $linkParams = [ 'href' => $value, 'class' => 'external free' ]; |
271 | if ( $wgNoFollowLinks ) { |
272 | $linkParams['rel'] = 'nofollow'; |
273 | } |
274 | return Html::element( 'a', $linkParams, $value ); |
275 | } |
276 | } elseif ( $type == 'Date' || $type == 'Datetime' ) { |
277 | // This should not get called - date fields |
278 | // have a separate formatting function. |
279 | return $value; |
280 | } elseif ( $type == 'Wikitext' || $type == 'Wikitext string' || $type == '' ) { |
281 | return CargoUtils::smartParse( $value, $parser ); |
282 | } elseif ( $type == 'Searchtext' ) { |
283 | if ( $escapeValue ) { |
284 | $value = htmlspecialchars( $value ); |
285 | } |
286 | if ( strlen( $value ) > 300 ) { |
287 | return substr( $value, 0, 300 ) . ' ...'; |
288 | } else { |
289 | return $value; |
290 | } |
291 | } |
292 | |
293 | // If it's not any of these specially-handled types, just |
294 | // return the value. |
295 | if ( $escapeValue ) { |
296 | $value = htmlspecialchars( $value ); |
297 | } |
298 | return $value; |
299 | } |
300 | |
301 | public static function formatDateFieldValue( $dateValue, $datePrecision, $type ) { |
302 | // Quick escape. |
303 | if ( $dateValue == '' ) { |
304 | return ''; |
305 | } |
306 | |
307 | $seconds = strtotime( $dateValue ); |
308 | // 'Y' adds leading zeroes to years with fewer than four digits, |
309 | // so remove them. |
310 | $yearString = ltrim( date( 'Y', $seconds ), '0' ); |
311 | if ( $datePrecision == CargoStore::YEAR_ONLY ) { |
312 | return $yearString; |
313 | } elseif ( $datePrecision == CargoStore::MONTH_ONLY ) { |
314 | return CargoDrilldownUtils::monthToString( date( 'm', $seconds ) ) . |
315 | " $yearString"; |
316 | } else { |
317 | // CargoStore::DATE_AND_TIME or |
318 | // CargoStore::DATE_ONLY |
319 | global $wgAmericanDates; |
320 | if ( $wgAmericanDates ) { |
321 | // We use MediaWiki's representation of month |
322 | // names, instead of PHP's, because its i18n |
323 | // support is of course far superior. |
324 | $dateText = CargoDrilldownUtils::monthToString( date( 'm', $seconds ) ); |
325 | $dateText .= ' ' . date( 'j', $seconds ) . ", $yearString"; |
326 | } else { |
327 | $dateText = "$yearString-" . date( 'm-d', $seconds ); |
328 | } |
329 | // @TODO - remove the redundant 'Date' check at some |
330 | // point. It's here because the "precision" constants |
331 | // changed a ittle in version 0.8. |
332 | if ( $type == 'Date' || $datePrecision == CargoStore::DATE_ONLY ) { |
333 | return $dateText; |
334 | } |
335 | |
336 | // It's a Datetime - add time as well. |
337 | global $wgCargo24HourTime; |
338 | if ( $wgCargo24HourTime ) { |
339 | $timeText = date( 'G:i:s', $seconds ); |
340 | } else { |
341 | $timeText = date( 'g:i:s A', $seconds ); |
342 | } |
343 | return "$dateText $timeText"; |
344 | } |
345 | } |
346 | |
347 | /** |
348 | * Based on MediaWiki's SqlSearchResult::getTextSnippet() |
349 | */ |
350 | public function getTextSnippet( $text, $terms ) { |
351 | foreach ( $terms as $i => $term ) { |
352 | // Try to map from a MySQL search to a PHP one - |
353 | // this code could probably be improved. |
354 | $term = str_replace( [ '"', "'", '+', '*' ], '', $term ); |
355 | // What is the point of this...? |
356 | if ( strpos( $term, '*' ) !== false ) { |
357 | $term = '\b' . $term . '\b'; |
358 | } |
359 | $terms[$i] = $term; |
360 | } |
361 | |
362 | // Replace newlines, etc. with spaces for better readability. |
363 | $text = preg_replace( '/\s+/', ' ', $text ); |
364 | $h = new SearchHighlighter(); |
365 | if ( count( $terms ) > 0 ) { |
366 | // In the core MediaWiki equivalent of this code, |
367 | // there is a check here of the flag |
368 | // $wgAdvancedSearchHighlighting. Instead, we always |
369 | // call the more expensive function, highlightText() |
370 | // rather than highlightSimple(), because we're not |
371 | // that concerned about performance. |
372 | return $h->highlightText( $text, $terms ); |
373 | } else { |
374 | return $h->highlightNone( $text ); |
375 | } |
376 | } |
377 | |
378 | /** |
379 | * @param CargoDisplayFormat $formatter |
380 | * @param array[] $queryResults |
381 | * @return mixed|string |
382 | */ |
383 | public function displayQueryResults( $formatter, $queryResults ) { |
384 | if ( count( $queryResults ) == 0 ) { |
385 | if ( array_key_exists( 'default', $this->mDisplayParams ) ) { |
386 | return $this->mDisplayParams['default']; |
387 | } else { |
388 | return '<em>' . wfMessage( 'table_pager_empty' )->escaped() . '</em>'; // default |
389 | } |
390 | } |
391 | |
392 | $formattedQueryResults = $this->getFormattedQueryResults( $queryResults, true ); |
393 | $text = ''; |
394 | |
395 | // If this is the 'template' format, let the formatter print |
396 | // out the intro and outro, so they can be parsed at the same |
397 | // time as the main body. In theory, this should be done for |
398 | // every result format, but in practice, probably only with |
399 | // 'template' could there be complex formatting (like a table |
400 | // with a header and footer) where this approach to parsing |
401 | // would make a difference. |
402 | if ( array_key_exists( 'intro', $this->mDisplayParams ) && !( $formatter instanceof CargoTemplateFormat ) ) { |
403 | $text .= CargoUtils::smartParse( $this->mDisplayParams['intro'], null ); |
404 | } |
405 | try { |
406 | $text .= $formatter->display( $queryResults, $formattedQueryResults, $this->mFieldDescriptions, |
407 | $this->mDisplayParams ); |
408 | } catch ( Exception $e ) { |
409 | return CargoUtils::formatError( $e->getMessage() ); |
410 | } |
411 | if ( array_key_exists( 'outro', $this->mDisplayParams ) && !( $formatter instanceof CargoTemplateFormat ) ) { |
412 | $text .= CargoUtils::smartParse( $this->mDisplayParams['outro'], null ); |
413 | } |
414 | return $text; |
415 | } |
416 | |
417 | /** |
418 | * Display the link to view more results, pointing to Special:ViewData. |
419 | */ |
420 | public function viewMoreResultsLink( $displayHTML = true ) { |
421 | $vd = Title::makeTitleSafe( NS_SPECIAL, 'ViewData' ); |
422 | if ( array_key_exists( 'more results text', $this->mDisplayParams ) ) { |
423 | $moreResultsText = htmlspecialchars( $this->mDisplayParams['more results text'] ); |
424 | // If the value is blank, don't show a link at all. |
425 | if ( $moreResultsText == '' ) { |
426 | return ''; |
427 | } |
428 | } else { |
429 | $moreResultsText = wfMessage( 'moredotdotdot' )->parse(); |
430 | } |
431 | |
432 | $queryStringParams = []; |
433 | $sqlQuery = $this->mSQLQuery; |
434 | $queryStringParams['tables'] = $sqlQuery->mTablesStr; |
435 | $queryStringParams['fields'] = $sqlQuery->mFieldsStr; |
436 | if ( $sqlQuery->mOrigWhereStr != '' ) { |
437 | $queryStringParams['where'] = $sqlQuery->mOrigWhereStr; |
438 | } |
439 | if ( $sqlQuery->mJoinOnStr != '' ) { |
440 | $queryStringParams['join_on'] = $sqlQuery->mJoinOnStr; |
441 | } |
442 | if ( $sqlQuery->mOrigGroupByStr != '' ) { |
443 | $queryStringParams['group_by'] = $sqlQuery->mOrigGroupByStr; |
444 | } |
445 | if ( $sqlQuery->mOrigHavingStr != '' ) { |
446 | $queryStringParams['having'] = $sqlQuery->mOrigHavingStr; |
447 | } |
448 | $queryStringParams['order_by'] = $sqlQuery->mOrigOrderBy; |
449 | if ( $this->mFormat != '' ) { |
450 | $queryStringParams['format'] = $this->mFormat; |
451 | } |
452 | $queryStringParams['offset'] = $sqlQuery->mQueryLimit; |
453 | $queryStringParams['limit'] = 100; // Is that a reasonable number in all cases? |
454 | |
455 | // Add format-specific params. |
456 | foreach ( $this->mDisplayParams as $key => $value ) { |
457 | $queryStringParams[$key] = $value; |
458 | } |
459 | |
460 | if ( $displayHTML ) { |
461 | $linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer(); |
462 | $link = CargoUtils::makeLink( $linkRenderer, $vd, $moreResultsText, [], $queryStringParams ); |
463 | return Html::rawElement( 'p', null, $link ); |
464 | } else { |
465 | // Display link as wikitext. |
466 | global $wgServer; |
467 | return '[' . $wgServer . $vd->getLinkURL( $queryStringParams ) . ' ' . $moreResultsText . ']'; |
468 | } |
469 | } |
470 | |
471 | } |