Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
34.34% covered (danger)
34.34%
68 / 198
30.00% covered (danger)
30.00%
3 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
CargoQueryDisplayer
34.34% covered (danger)
34.34%
68 / 198
30.00% covered (danger)
30.00%
3 / 10
2279.79
0.00% covered (danger)
0.00%
0 / 1
 newFromSQLQuery
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 getAllFormatClasses
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getFormatClass
71.43% covered (warning)
71.43%
5 / 7
0.00% covered (danger)
0.00%
0 / 1
3.21
 getFormatter
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getFormattedQueryResults
52.94% covered (warning)
52.94%
27 / 51
0.00% covered (danger)
0.00%
0 / 1
116.64
 formatFieldValue
27.27% covered (danger)
27.27%
15 / 55
0.00% covered (danger)
0.00%
0 / 1
226.49
 formatDateFieldValue
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
72
 getTextSnippet
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
20
 displayQueryResults
66.67% covered (warning)
66.67%
10 / 15
0.00% covered (danger)
0.00%
0 / 1
10.37
 viewMoreResultsLink
0.00% covered (danger)
0.00%
0 / 30
0.00% covered (danger)
0.00%
0 / 1
110
1<?php
2
3use MediaWiki\Html\Html;
4use MediaWiki\Linker\Linker;
5use MediaWiki\MediaWikiServices;
6use MediaWiki\Title\Title;
7
8/**
9 * CargoQueryDisplayer - class for displaying query results.
10 *
11 * @author Yaron Koren
12 * @ingroup Cargo
13 */
14
15class 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">&bull;</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}