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