Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
33.66% covered (danger)
33.66%
68 / 202
30.00% covered (danger)
30.00%
3 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
CargoQueryDisplayer
33.66% covered (danger)
33.66%
68 / 202
30.00% covered (danger)
30.00%
3 / 10
2508.37
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
25.42% covered (danger)
25.42%
15 / 59
0.00% covered (danger)
0.00%
0 / 1
306.38
 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            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}