Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
34.87% covered (danger)
34.87%
68 / 195
30.00% covered (danger)
30.00%
3 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
CargoQueryDisplayer
34.87% covered (danger)
34.87%
68 / 195
30.00% covered (danger)
30.00%
3 / 10
2177.96
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
28.85% covered (danger)
28.85%
15 / 52
0.00% covered (danger)
0.00%
0 / 1
196.36
 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\MediaWikiServices;
4
5/**
6 * CargoQueryDisplayer - class for displaying query results.
7 *
8 * @author Yaron Koren
9 * @ingroup Cargo
10 */
11
12class 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">&bull;</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            $file = MediaWikiServices::getInstance()->getRepoGroup()->findFile( $title );
241            return Linker::makeThumbLinkObj(
242                $title,
243                $file,
244                $value,
245                ''
246            );
247        } elseif ( $type == 'URL' ) {
248            // Validate URL - regexp code copied from Sanitizer::validateAttributes().
249            $hrefExp = '/^(' . wfUrlProtocols() . ')[^\s]+$/';
250            if ( !preg_match( $hrefExp, $value ) ) {
251                if ( $escapeValue ) {
252                    return htmlspecialchars( $value );
253                } else {
254                    return $value;
255                }
256            } elseif ( array_key_exists( 'link text', $fieldDescription->mOtherParams ) ) {
257                return Html::element( 'a', [ 'href' => $value ],
258                        $fieldDescription->mOtherParams['link text'] );
259            } else {
260                // Otherwise, display the URL as a link.
261                global $wgNoFollowLinks;
262                $linkParams = [ 'href' => $value, 'class' => 'external free' ];
263                if ( $wgNoFollowLinks ) {
264                    $linkParams['rel'] = 'nofollow';
265                }
266                return Html::element( 'a', $linkParams, $value );
267            }
268        } elseif ( $type == 'Date' || $type == 'Datetime' ) {
269            // This should not get called - date fields
270            // have a separate formatting function.
271            return $value;
272        } elseif ( $type == 'Wikitext' || $type == 'Wikitext string' || $type == '' ) {
273            return CargoUtils::smartParse( $value, $parser );
274        } elseif ( $type == 'Searchtext' ) {
275            if ( $escapeValue ) {
276                $value = htmlspecialchars( $value );
277            }
278            if ( strlen( $value ) > 300 ) {
279                return substr( $value, 0, 300 ) . ' ...';
280            } else {
281                return $value;
282            }
283        }
284
285        // If it's not any of these specially-handled types, just
286        // return the value.
287        if ( $escapeValue ) {
288            $value = htmlspecialchars( $value );
289        }
290        return $value;
291    }
292
293    public static function formatDateFieldValue( $dateValue, $datePrecision, $type ) {
294        // Quick escape.
295        if ( $dateValue == '' ) {
296            return '';
297        }
298
299        $seconds = strtotime( $dateValue );
300        // 'Y' adds leading zeroes to years with fewer than four digits,
301        // so remove them.
302        $yearString = ltrim( date( 'Y', $seconds ), '0' );
303        if ( $datePrecision == CargoStore::YEAR_ONLY ) {
304            return $yearString;
305        } elseif ( $datePrecision == CargoStore::MONTH_ONLY ) {
306            return CargoDrilldownUtils::monthToString( date( 'm', $seconds ) ) .
307                " $yearString";
308        } else {
309            // CargoStore::DATE_AND_TIME or
310            // CargoStore::DATE_ONLY
311            global $wgAmericanDates;
312            if ( $wgAmericanDates ) {
313                // We use MediaWiki's representation of month
314                // names, instead of PHP's, because its i18n
315                // support is of course far superior.
316                $dateText = CargoDrilldownUtils::monthToString( date( 'm', $seconds ) );
317                $dateText .= ' ' . date( 'j', $seconds ) . "$yearString";
318            } else {
319                $dateText = "$yearString-" . date( 'm-d', $seconds );
320            }
321            // @TODO - remove the redundant 'Date' check at some
322            // point. It's here because the "precision" constants
323            // changed a ittle in version 0.8.
324            if ( $type == 'Date' || $datePrecision == CargoStore::DATE_ONLY ) {
325                return $dateText;
326            }
327
328            // It's a Datetime - add time as well.
329            global $wgCargo24HourTime;
330            if ( $wgCargo24HourTime ) {
331                $timeText = date( 'G:i:s', $seconds );
332            } else {
333                $timeText = date( 'g:i:s A', $seconds );
334            }
335            return "$dateText $timeText";
336        }
337    }
338
339    /**
340     * Based on MediaWiki's SqlSearchResult::getTextSnippet()
341     */
342    public function getTextSnippet( $text, $terms ) {
343        foreach ( $terms as $i => $term ) {
344            // Try to map from a MySQL search to a PHP one -
345            // this code could probably be improved.
346            $term = str_replace( [ '"', "'", '+', '*' ], '', $term );
347            // What is the point of this...?
348            if ( strpos( $term, '*' ) !== false ) {
349                $term = '\b' . $term . '\b';
350            }
351            $terms[$i] = $term;
352        }
353
354        // Replace newlines, etc. with spaces for better readability.
355        $text = preg_replace( '/\s+/', ' ', $text );
356        $h = new SearchHighlighter();
357        if ( count( $terms ) > 0 ) {
358            // In the core MediaWiki equivalent of this code,
359            // there is a check here of the flag
360            // $wgAdvancedSearchHighlighting. Instead, we always
361            // call the more expensive function, highlightText()
362            // rather than highlightSimple(), because we're not
363            // that concerned about performance.
364            return $h->highlightText( $text, $terms );
365        } else {
366            return $h->highlightNone( $text );
367        }
368    }
369
370    /**
371     * @param CargoDisplayFormat $formatter
372     * @param array[] $queryResults
373     * @return mixed|string
374     */
375    public function displayQueryResults( $formatter, $queryResults ) {
376        if ( count( $queryResults ) == 0 ) {
377            if ( array_key_exists( 'default', $this->mDisplayParams ) ) {
378                return $this->mDisplayParams['default'];
379            } else {
380                return '<em>' . wfMessage( 'table_pager_empty' )->escaped() . '</em>'; // default
381            }
382        }
383
384        $formattedQueryResults = $this->getFormattedQueryResults( $queryResults, true );
385        $text = '';
386
387        // If this is the 'template' format, let the formatter print
388        // out the intro and outro, so they can be parsed at the same
389        // time as the main body. In theory, this should be done for
390        // every result format, but in practice, probably only with
391        // 'template' could there be complex formatting (like a table
392        // with a header and footer) where this approach to parsing
393        // would make a difference.
394        if ( array_key_exists( 'intro', $this->mDisplayParams ) && !( $formatter instanceof CargoTemplateFormat ) ) {
395            $text .= CargoUtils::smartParse( $this->mDisplayParams['intro'], null );
396        }
397        try {
398            $text .= $formatter->display( $queryResults, $formattedQueryResults, $this->mFieldDescriptions,
399                $this->mDisplayParams );
400        } catch ( Exception $e ) {
401            return CargoUtils::formatError( $e->getMessage() );
402        }
403        if ( array_key_exists( 'outro', $this->mDisplayParams ) && !( $formatter instanceof CargoTemplateFormat ) ) {
404            $text .= CargoUtils::smartParse( $this->mDisplayParams['outro'], null );
405        }
406        return $text;
407    }
408
409    /**
410     * Display the link to view more results, pointing to Special:ViewData.
411     */
412    public function viewMoreResultsLink( $displayHTML = true ) {
413        $vd = Title::makeTitleSafe( NS_SPECIAL, 'ViewData' );
414        if ( array_key_exists( 'more results text', $this->mDisplayParams ) ) {
415            $moreResultsText = htmlspecialchars( $this->mDisplayParams['more results text'] );
416            // If the value is blank, don't show a link at all.
417            if ( $moreResultsText == '' ) {
418                return '';
419            }
420        } else {
421            $moreResultsText = wfMessage( 'moredotdotdot' )->parse();
422        }
423
424        $queryStringParams = [];
425        $sqlQuery = $this->mSQLQuery;
426        $queryStringParams['tables'] = $sqlQuery->mTablesStr;
427        $queryStringParams['fields'] = $sqlQuery->mFieldsStr;
428        if ( $sqlQuery->mOrigWhereStr != '' ) {
429            $queryStringParams['where'] = $sqlQuery->mOrigWhereStr;
430        }
431        if ( $sqlQuery->mJoinOnStr != '' ) {
432            $queryStringParams['join_on'] = $sqlQuery->mJoinOnStr;
433        }
434        if ( $sqlQuery->mOrigGroupByStr != '' ) {
435            $queryStringParams['group_by'] = $sqlQuery->mOrigGroupByStr;
436        }
437        if ( $sqlQuery->mOrigHavingStr != '' ) {
438            $queryStringParams['having'] = $sqlQuery->mOrigHavingStr;
439        }
440        $queryStringParams['order_by'] = $sqlQuery->mOrigOrderBy;
441        if ( $this->mFormat != '' ) {
442            $queryStringParams['format'] = $this->mFormat;
443        }
444        $queryStringParams['offset'] = $sqlQuery->mQueryLimit;
445        $queryStringParams['limit'] = 100; // Is that a reasonable number in all cases?
446
447        // Add format-specific params.
448        foreach ( $this->mDisplayParams as $key => $value ) {
449            $queryStringParams[$key] = $value;
450        }
451
452        if ( $displayHTML ) {
453            $linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer();
454            $link = CargoUtils::makeLink( $linkRenderer, $vd, $moreResultsText, [], $queryStringParams );
455            return Html::rawElement( 'p', null, $link );
456        } else {
457            // Display link as wikitext.
458            global $wgServer;
459            return '[' . $wgServer . $vd->getLinkURL( $queryStringParams ) . ' ' . $moreResultsText . ']';
460        }
461    }
462
463}