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