Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
47.33% |
426 / 900 |
|
20.83% |
5 / 24 |
CRAP | |
0.00% |
0 / 1 |
| FormatMetadata | |
47.33% |
426 / 900 |
|
20.83% |
5 / 24 |
19080.92 | |
0.00% |
0 / 1 |
| setSingleLanguage | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| getFormattedData | |
75.00% |
3 / 4 |
|
0.00% |
0 / 1 |
2.06 | |||
| makeFormattedData | |
61.67% |
317 / 514 |
|
0.00% |
0 / 1 |
2919.21 | |||
| flattenArrayReal | |
54.72% |
29 / 53 |
|
0.00% |
0 / 1 |
83.03 | |||
| langItem | |
25.00% |
7 / 28 |
|
0.00% |
0 / 1 |
52.19 | |||
| literal | |
66.67% |
2 / 3 |
|
0.00% |
0 / 1 |
2.15 | |||
| exifMsg | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
2 | |||
| formatNum | |
76.92% |
20 / 26 |
|
0.00% |
0 / 1 |
8.79 | |||
| formatFraction | |
88.89% |
8 / 9 |
|
0.00% |
0 / 1 |
3.01 | |||
| gcd | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
2 | |||
| convertNewsCode | |
0.00% |
0 / 26 |
|
0.00% |
0 / 1 |
12 | |||
| formatCoords | |
0.00% |
0 / 22 |
|
0.00% |
0 / 1 |
56 | |||
| collapseContactInfo | |
0.00% |
0 / 60 |
|
0.00% |
0 / 1 |
552 | |||
| getVisibleFields | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
3 | |||
| fetchExtendedMetadata | |
0.00% |
0 / 31 |
|
0.00% |
0 / 1 |
30 | |||
| getExtendedMetadataFromFile | |
0.00% |
0 / 21 |
|
0.00% |
0 / 1 |
30 | |||
| getExtendedMetadataFromHook | |
0.00% |
0 / 12 |
|
0.00% |
0 / 1 |
12 | |||
| resolveMultilangValue | |
0.00% |
0 / 14 |
|
0.00% |
0 / 1 |
72 | |||
| resolveMultivalueValue | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
6 | |||
| resolveMultilangMetadata | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
20 | |||
| discardMultipleValues | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
42 | |||
| sanitizeArrayForAPI | |
0.00% |
0 / 17 |
|
0.00% |
0 / 1 |
56 | |||
| sanitizeKeyForAPI | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
12 | |||
| getPriorityLanguages | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
1 | |||
| 1 | <?php |
| 2 | /** |
| 3 | * Formatting of image metadata values into human readable form. |
| 4 | * |
| 5 | * @license GPL-2.0-or-later |
| 6 | * @ingroup Media |
| 7 | * @author Ævar Arnfjörð Bjarmason <avarab@gmail.com> |
| 8 | * @copyright Copyright © 2005, Ævar Arnfjörð Bjarmason, 2009 Brent Garber, 2010 Brian Wolff |
| 9 | * @license GPL-2.0-or-later |
| 10 | * @see http://exif.org/Exif2-2.PDF The Exif 2.2 specification |
| 11 | * @file |
| 12 | */ |
| 13 | |
| 14 | use MediaWiki\Api\ApiResult; |
| 15 | use MediaWiki\Context\ContextSource; |
| 16 | use MediaWiki\Context\IContextSource; |
| 17 | use MediaWiki\FileRepo\File\File; |
| 18 | use MediaWiki\FileRepo\File\ForeignAPIFile; |
| 19 | use MediaWiki\HookContainer\ProtectedHookAccessorTrait; |
| 20 | use MediaWiki\Html\Html; |
| 21 | use MediaWiki\Logger\LoggerFactory; |
| 22 | use MediaWiki\MediaWikiServices; |
| 23 | use Wikimedia\Timestamp\TimestampFormat as TS; |
| 24 | |
| 25 | /** |
| 26 | * Format Image metadata values into a human readable form. |
| 27 | * |
| 28 | * Note lots of these messages use the prefix 'exif' even though |
| 29 | * they may not be exif properties. For example 'exif-ImageDescription' |
| 30 | * can be the Exif ImageDescription, or it could be the iptc-iim caption |
| 31 | * property, or it could be the xmp dc:description property. This |
| 32 | * is because these messages should be independent of how the data is |
| 33 | * stored, sine the user doesn't care if the description is stored in xmp, |
| 34 | * exif, etc only that its a description. (Additionally many of these properties |
| 35 | * are merged together following the MWG standard, such that for example, |
| 36 | * exif properties override XMP properties that mean the same thing if |
| 37 | * there is a conflict). |
| 38 | * |
| 39 | * It should perhaps use a prefix like 'metadata' instead, but there |
| 40 | * is already a large number of messages using the 'exif' prefix. |
| 41 | * |
| 42 | * @ingroup Media |
| 43 | * @since 1.23 the class extends ContextSource and various formerly-public |
| 44 | * internal methods are private |
| 45 | */ |
| 46 | class FormatMetadata extends ContextSource { |
| 47 | use ProtectedHookAccessorTrait; |
| 48 | |
| 49 | /** |
| 50 | * Only output a single language for multi-language fields |
| 51 | * @var bool |
| 52 | * @since 1.23 |
| 53 | */ |
| 54 | protected $singleLang = false; |
| 55 | |
| 56 | /** |
| 57 | * Trigger only outputting single language for multilanguage fields |
| 58 | * |
| 59 | * @param bool $val |
| 60 | * @since 1.23 |
| 61 | */ |
| 62 | public function setSingleLanguage( $val ) { |
| 63 | $this->singleLang = $val; |
| 64 | } |
| 65 | |
| 66 | /** |
| 67 | * Numbers given by Exif user agents are often magical, that is they |
| 68 | * should be replaced by a detailed explanation depending on their |
| 69 | * value which most of the time are plain integers. This function |
| 70 | * formats Exif (and other metadata) values into human readable form. |
| 71 | * |
| 72 | * This is the usual entry point for this class. |
| 73 | * |
| 74 | * @param array $tags The Exif data to format ( as returned by |
| 75 | * Exif::getFilteredData() or BitmapMetadataHandler ) |
| 76 | * @param IContextSource|false $context |
| 77 | * @return array |
| 78 | */ |
| 79 | public static function getFormattedData( $tags, $context = false ) { |
| 80 | $obj = new self; |
| 81 | if ( $context ) { |
| 82 | $obj->setContext( $context ); |
| 83 | } |
| 84 | |
| 85 | return $obj->makeFormattedData( $tags ); |
| 86 | } |
| 87 | |
| 88 | /** |
| 89 | * Numbers given by Exif user agents are often magical, that is they |
| 90 | * should be replaced by a detailed explanation depending on their |
| 91 | * value which most of the time are plain integers. This function |
| 92 | * formats Exif (and other metadata) values into human readable form. |
| 93 | * |
| 94 | * @param array $tags The Exif data to format ( as returned by |
| 95 | * Exif::getFilteredData() or BitmapMetadataHandler ) |
| 96 | * @return array |
| 97 | * @since 1.23 |
| 98 | */ |
| 99 | public function makeFormattedData( $tags ) { |
| 100 | $resolutionunit = !isset( $tags['ResolutionUnit'] ) || $tags['ResolutionUnit'] == 2 ? 2 : 3; |
| 101 | unset( $tags['ResolutionUnit'] ); |
| 102 | |
| 103 | // Ignore these complex values |
| 104 | unset( $tags['HasExtendedXMP'] ); |
| 105 | unset( $tags['AuthorsPosition'] ); |
| 106 | unset( $tags['LocationCreated'] ); |
| 107 | unset( $tags['LocationShown'] ); |
| 108 | unset( $tags['GPSAltitudeRef'] ); |
| 109 | |
| 110 | foreach ( $tags as $tag => &$vals ) { |
| 111 | // This seems ugly to wrap non-array's in an array just to unwrap again, |
| 112 | // especially when most of the time it is not an array |
| 113 | if ( !is_array( $vals ) ) { |
| 114 | $vals = [ $vals ]; |
| 115 | } |
| 116 | |
| 117 | // _type is a special value to say what array type |
| 118 | if ( isset( $vals['_type'] ) ) { |
| 119 | $type = $vals['_type']; |
| 120 | unset( $vals['_type'] ); |
| 121 | } else { |
| 122 | $type = 'ul'; // default unordered list. |
| 123 | } |
| 124 | |
| 125 | // _formatted is a special value to indicate the subclass |
| 126 | // already handled & formatted this tag as wikitext |
| 127 | if ( isset( $tags[$tag]['_formatted'] ) ) { |
| 128 | $tags[$tag] = $this->flattenArrayReal( |
| 129 | $tags[$tag]['_formatted'], $type |
| 130 | ); |
| 131 | continue; |
| 132 | } |
| 133 | |
| 134 | // This is done differently as the tag is an array. |
| 135 | if ( $tag === 'GPSTimeStamp' && count( $vals ) === 3 ) { |
| 136 | // hour min sec array |
| 137 | |
| 138 | $h = explode( '/', $vals[0], 2 ); |
| 139 | $m = explode( '/', $vals[1], 2 ); |
| 140 | $s = explode( '/', $vals[2], 2 ); |
| 141 | |
| 142 | // this should already be validated |
| 143 | // when loaded from file, but it could |
| 144 | // come from a foreign repo, so be |
| 145 | // paranoid. |
| 146 | if ( !isset( $h[1] ) |
| 147 | || !isset( $m[1] ) |
| 148 | || !isset( $s[1] ) |
| 149 | || $h[1] == 0 |
| 150 | || $m[1] == 0 |
| 151 | || $s[1] == 0 |
| 152 | ) { |
| 153 | continue; |
| 154 | } |
| 155 | $vals = str_pad( (string)( (int)$h[0] / (int)$h[1] ), 2, '0', STR_PAD_LEFT ) |
| 156 | . ':' . str_pad( (string)( (int)$m[0] / (int)$m[1] ), 2, '0', STR_PAD_LEFT ) |
| 157 | . ':' . str_pad( (string)( (int)$s[0] / (int)$s[1] ), 2, '0', STR_PAD_LEFT ); |
| 158 | |
| 159 | $time = wfTimestamp( TS::MW, '1971:01:01 ' . $vals ); |
| 160 | // the 1971:01:01 is just a placeholder, and not shown to user. |
| 161 | if ( $time && (int)$time > 0 ) { |
| 162 | $vals = $this->getLanguage()->time( $time ); |
| 163 | } |
| 164 | continue; |
| 165 | } |
| 166 | |
| 167 | // The contact info is a multi-valued field |
| 168 | // instead of the other props which are single |
| 169 | // valued (mostly) so handle as a special case. |
| 170 | if ( $tag === 'Contact' || $tag === 'CreatorContactInfo' ) { |
| 171 | $vals = $this->collapseContactInfo( $vals ); |
| 172 | continue; |
| 173 | } |
| 174 | |
| 175 | foreach ( $vals as &$val ) { |
| 176 | switch ( $tag ) { |
| 177 | case 'Compression': |
| 178 | $val = match ( $val ) { |
| 179 | 1, 2, 3, 4, 5, 6, 7, 8, |
| 180 | 32773, |
| 181 | 32946, |
| 182 | 34712 => $this->exifMsg( $tag, $val ), |
| 183 | /* If not recognized, display as is. */ |
| 184 | default => $this->literal( $val ) |
| 185 | }; |
| 186 | break; |
| 187 | |
| 188 | case 'PhotometricInterpretation': |
| 189 | $val = match ( $val ) { |
| 190 | 0, 1, 2, 3, 4, 5, 6, 8, 9, 10, |
| 191 | 32803, |
| 192 | 34892 => $this->exifMsg( $tag, $val ), |
| 193 | /* If not recognized, display as is. */ |
| 194 | default => $this->literal( $val ) |
| 195 | }; |
| 196 | break; |
| 197 | |
| 198 | case 'Orientation': |
| 199 | $val = match ( $val ) { |
| 200 | 1, 2, 3, 4, 5, 6, 7, 8 => $this->exifMsg( $tag, $val ), |
| 201 | /* If not recognized, display as is. */ |
| 202 | default => $this->literal( $val ) |
| 203 | }; |
| 204 | break; |
| 205 | |
| 206 | case 'PlanarConfiguration': |
| 207 | // TODO: YCbCrSubSampling |
| 208 | case 'YCbCrPositioning': |
| 209 | $val = match ( $val ) { |
| 210 | 1, 2 => $this->exifMsg( $tag, $val ), |
| 211 | /* If not recognized, display as is. */ |
| 212 | default => $this->literal( $val ) |
| 213 | }; |
| 214 | break; |
| 215 | |
| 216 | case 'XResolution': |
| 217 | case 'YResolution': |
| 218 | $val = match ( $resolutionunit ) { |
| 219 | 2 => $this->exifMsg( 'XYResolution', 'i', $this->formatNum( $val ) ), |
| 220 | 3 => $this->exifMsg( 'XYResolution', 'c', $this->formatNum( $val ) ), |
| 221 | /* If not recognized, display as is. */ |
| 222 | default => $this->literal( $val ) |
| 223 | }; |
| 224 | break; |
| 225 | |
| 226 | // TODO: YCbCrCoefficients #p27 (see annex E) |
| 227 | case 'ExifVersion': |
| 228 | // PHP likes to be the odd one out with casing of FlashPixVersion; |
| 229 | // https://www.exif.org/Exif2-2.PDF#page=32 and |
| 230 | // https://www.digitalgalen.net/Documents/External/XMP/XMPSpecificationPart2.pdf#page=51 |
| 231 | // both use FlashpixVersion. However, since at least 2002, PHP has used FlashPixVersion at |
| 232 | // https://github.com/php/php-src/blame/master/ext/exif/exif.c#L725 |
| 233 | case 'FlashPixVersion': |
| 234 | // But we can still get the correct casing from |
| 235 | // Wikimedia\XMPReader on PDFs |
| 236 | case 'FlashpixVersion': |
| 237 | $val = $this->literal( (int)$val / 100 ); |
| 238 | break; |
| 239 | |
| 240 | case 'ColorSpace': |
| 241 | $val = match ( $val ) { |
| 242 | 1, 65535 => $this->exifMsg( $tag, $val ), |
| 243 | /* If not recognized, display as is. */ |
| 244 | default => $this->literal( $val ) |
| 245 | }; |
| 246 | break; |
| 247 | |
| 248 | case 'ComponentsConfiguration': |
| 249 | $val = match ( $val ) { |
| 250 | 0, 1, 2, 3, 4, 5, 6 => $this->exifMsg( $tag, $val ), |
| 251 | /* If not recognized, display as is. */ |
| 252 | default => $this->literal( $val ) |
| 253 | }; |
| 254 | break; |
| 255 | |
| 256 | case 'DateTime': |
| 257 | case 'DateTimeOriginal': |
| 258 | case 'DateTimeDigitized': |
| 259 | case 'DateTimeReleased': |
| 260 | case 'DateTimeExpires': |
| 261 | case 'GPSDateStamp': |
| 262 | case 'dc-date': |
| 263 | case 'DateTimeMetadata': |
| 264 | case 'FirstPhotoDate': |
| 265 | case 'LastPhotoDate': |
| 266 | if ( $val === null ) { |
| 267 | // T384879 - we don't need to call literal to turn this into a string, but |
| 268 | // we might as well call it for consistency and future proofing of the default value |
| 269 | $val = $this->literal( $val ); |
| 270 | break; |
| 271 | } |
| 272 | |
| 273 | if ( $val === '0000:00:00 00:00:00' || $val === ' : : : : ' ) { |
| 274 | $val = $this->msg( 'exif-unknowndate' )->text(); |
| 275 | break; |
| 276 | } |
| 277 | if ( preg_match( |
| 278 | '/^(?:\d{4}):(?:\d\d):(?:\d\d) (?:\d\d):(?:\d\d):(?:\d\d)$/D', |
| 279 | $val |
| 280 | ) ) { |
| 281 | // Full date. |
| 282 | $time = wfTimestamp( TS::MW, $val ); |
| 283 | if ( $time && (int)$time > 0 ) { |
| 284 | $val = $this->getLanguage()->timeanddate( $time ); |
| 285 | break; |
| 286 | } |
| 287 | } elseif ( preg_match( '/^(?:\d{4}):(?:\d\d):(?:\d\d) (?:\d\d):(?:\d\d)$/D', $val ) ) { |
| 288 | // No second field. Still format the same |
| 289 | // since timeanddate doesn't include seconds anyways, |
| 290 | // but second still available in api |
| 291 | $time = wfTimestamp( TS::MW, $val . ':00' ); |
| 292 | if ( $time && (int)$time > 0 ) { |
| 293 | $val = $this->getLanguage()->timeanddate( $time ); |
| 294 | break; |
| 295 | } |
| 296 | } elseif ( preg_match( '/^(?:\d{4}):(?:\d\d):(?:\d\d)$/D', $val ) ) { |
| 297 | // If only the date but not the time is filled in. |
| 298 | $time = wfTimestamp( TS::MW, substr( $val, 0, 4 ) |
| 299 | . substr( $val, 5, 2 ) |
| 300 | . substr( $val, 8, 2 ) |
| 301 | . '000000' ); |
| 302 | if ( $time && (int)$time > 0 ) { |
| 303 | $val = $this->getLanguage()->date( $time ); |
| 304 | break; |
| 305 | } |
| 306 | } |
| 307 | // else it will just output $val without formatting it. |
| 308 | $val = $this->literal( $val ); |
| 309 | break; |
| 310 | |
| 311 | case 'ExposureProgram': |
| 312 | $val = match ( $val ) { |
| 313 | 0, 1, 2, 3, 4, 5, 6, 7, 8 => $this->exifMsg( $tag, $val ), |
| 314 | /* If not recognized, display as is. */ |
| 315 | default => $this->literal( $val ) |
| 316 | }; |
| 317 | break; |
| 318 | |
| 319 | case 'SubjectDistance': |
| 320 | $val = $this->exifMsg( $tag, '', $this->formatNum( $val ) ); |
| 321 | break; |
| 322 | |
| 323 | case 'MeteringMode': |
| 324 | $val = match ( $val ) { |
| 325 | 0, 1, 2, 3, 4, 5, 6, 7, 255 => $this->exifMsg( $tag, $val ), |
| 326 | /* If not recognized, display as is. */ |
| 327 | default => $this->literal( $val ) |
| 328 | }; |
| 329 | break; |
| 330 | |
| 331 | case 'LightSource': |
| 332 | $val = match ( $val ) { |
| 333 | 0, 1, 2, 3, 4, 9, 10, 11, 12, 13, 14, 15, 17, 18, 19, 20, 21, 22, 23, 24, |
| 334 | 255 => $this->exifMsg( $tag, $val ), |
| 335 | /* If not recognized, display as is. */ |
| 336 | default => $this->literal( $val ) |
| 337 | }; |
| 338 | break; |
| 339 | |
| 340 | case 'Flash': |
| 341 | if ( $val === '' ) { |
| 342 | $val = 0; |
| 343 | } |
| 344 | $flashDecode = [ |
| 345 | 'fired' => $val & 0b00000001, |
| 346 | 'return' => ( $val & 0b00000110 ) >> 1, |
| 347 | 'mode' => ( $val & 0b00011000 ) >> 3, |
| 348 | 'function' => ( $val & 0b00100000 ) >> 5, |
| 349 | 'redeye' => ( $val & 0b01000000 ) >> 6, |
| 350 | // 'reserved' => ( $val & 0b10000000 ) >> 7, |
| 351 | ]; |
| 352 | $flashMsgs = []; |
| 353 | # We do not need to handle unknown values since all are used. |
| 354 | foreach ( $flashDecode as $subTag => $subValue ) { |
| 355 | # We do not need any message for zeroed values. |
| 356 | if ( $subTag !== 'fired' && $subValue === 0 ) { |
| 357 | continue; |
| 358 | } |
| 359 | $fullTag = $tag . '-' . $subTag; |
| 360 | $flashMsgs[] = $this->exifMsg( $fullTag, $subValue ); |
| 361 | } |
| 362 | $val = $this->getLanguage()->commaList( $flashMsgs ); |
| 363 | break; |
| 364 | |
| 365 | case 'FocalPlaneResolutionUnit': |
| 366 | $val = match ( $val ) { |
| 367 | 2 => $this->exifMsg( $tag, $val ), |
| 368 | /* If not recognized, display as is. */ |
| 369 | default => $this->literal( $val ) |
| 370 | }; |
| 371 | break; |
| 372 | |
| 373 | case 'SensingMethod': |
| 374 | $val = match ( $val ) { |
| 375 | 1, 2, 3, 4, 5, 7, 8 => $this->exifMsg( $tag, $val ), |
| 376 | /* If not recognized, display as is. */ |
| 377 | default => $this->literal( $val ) |
| 378 | }; |
| 379 | break; |
| 380 | |
| 381 | case 'FileSource': |
| 382 | $val = match ( $val ) { |
| 383 | 3 => $this->exifMsg( $tag, $val ), |
| 384 | /* If not recognized, display as is. */ |
| 385 | default => $this->literal( $val ) |
| 386 | }; |
| 387 | break; |
| 388 | |
| 389 | case 'SceneType': |
| 390 | $val = match ( $val ) { |
| 391 | 1 => $this->exifMsg( $tag, $val ), |
| 392 | /* If not recognized, display as is. */ |
| 393 | default => $this->literal( $val ) |
| 394 | }; |
| 395 | break; |
| 396 | |
| 397 | case 'CustomRendered': |
| 398 | $val = match ( $val ) { |
| 399 | 0, /* normal */ |
| 400 | 1, /* custom */ |
| 401 | /* The following are unofficial Apple additions */ |
| 402 | 2, /* HDR (no original saved) */ |
| 403 | 3, /* HDR (original saved) */ |
| 404 | 4, /* Original (for HDR) */ |
| 405 | 6, /* Panorama */ |
| 406 | 7, /* Portrait HDR */ |
| 407 | 8 /* Portrait */ => $this->exifMsg( $tag, $val ), |
| 408 | /* If not recognized, display as is. */ |
| 409 | default => $this->literal( $val ) |
| 410 | }; |
| 411 | break; |
| 412 | |
| 413 | case 'ExposureMode': |
| 414 | case 'Contrast': |
| 415 | case 'Saturation': |
| 416 | case 'Sharpness': |
| 417 | $val = match ( $val ) { |
| 418 | 0, 1, 2 => $this->exifMsg( $tag, $val ), |
| 419 | /* If not recognized, display as is. */ |
| 420 | default => $this->literal( $val ) |
| 421 | }; |
| 422 | break; |
| 423 | |
| 424 | case 'WhiteBalance': |
| 425 | $val = match ( $val ) { |
| 426 | 0, 1 => $this->exifMsg( $tag, $val ), |
| 427 | /* If not recognized, display as is. */ |
| 428 | default => $this->literal( $val ) |
| 429 | }; |
| 430 | break; |
| 431 | |
| 432 | case 'SceneCaptureType': |
| 433 | case 'SubjectDistanceRange': |
| 434 | $val = match ( $val ) { |
| 435 | 0, 1, 2, 3 => $this->exifMsg( $tag, $val ), |
| 436 | /* If not recognized, display as is. */ |
| 437 | default => $this->literal( $val ) |
| 438 | }; |
| 439 | break; |
| 440 | |
| 441 | case 'GainControl': |
| 442 | $val = match ( $val ) { |
| 443 | 0, 1, 2, 3, 4 => $this->exifMsg( $tag, $val ), |
| 444 | /* If not recognized, display as is. */ |
| 445 | default => $this->literal( $val ) |
| 446 | }; |
| 447 | break; |
| 448 | |
| 449 | // The GPS...Ref values are kept for compatibility, probably won't be reached. |
| 450 | case 'GPSLatitudeRef': |
| 451 | case 'GPSDestLatitudeRef': |
| 452 | $val = match ( $val ) { |
| 453 | 'N', 'S' => $this->exifMsg( 'GPSLatitude', $val ), |
| 454 | /* If not recognized, display as is. */ |
| 455 | default => $this->literal( $val ) |
| 456 | }; |
| 457 | break; |
| 458 | |
| 459 | case 'GPSLongitudeRef': |
| 460 | case 'GPSDestLongitudeRef': |
| 461 | $val = match ( $val ) { |
| 462 | 'E', 'W' => $this->exifMsg( 'GPSLongitude', $val ), |
| 463 | /* If not recognized, display as is. */ |
| 464 | default => $this->literal( $val ) |
| 465 | }; |
| 466 | break; |
| 467 | |
| 468 | case 'GPSAltitude': |
| 469 | if ( $val < 0 ) { |
| 470 | $val = $this->exifMsg( 'GPSAltitude', 'below-sealevel', $this->formatNum( -$val, 3 ) ); |
| 471 | } else { |
| 472 | $val = $this->exifMsg( 'GPSAltitude', 'above-sealevel', $this->formatNum( $val, 3 ) ); |
| 473 | } |
| 474 | break; |
| 475 | |
| 476 | case 'GPSStatus': |
| 477 | $val = match ( $val ) { |
| 478 | 'A', 'V' => $this->exifMsg( $tag, $val ), |
| 479 | /* If not recognized, display as is. */ |
| 480 | default => $this->literal( $val ) |
| 481 | }; |
| 482 | break; |
| 483 | |
| 484 | case 'GPSMeasureMode': |
| 485 | $val = match ( $val ) { |
| 486 | 2, 3 => $this->exifMsg( $tag, $val ), |
| 487 | /* If not recognized, display as is. */ |
| 488 | default => $this->literal( $val ) |
| 489 | }; |
| 490 | break; |
| 491 | |
| 492 | case 'GPSTrackRef': |
| 493 | case 'GPSImgDirectionRef': |
| 494 | case 'GPSDestBearingRef': |
| 495 | $val = match ( $val ) { |
| 496 | 'T', 'M' => $this->exifMsg( 'GPSDirection', $val ), |
| 497 | /* If not recognized, display as is. */ |
| 498 | default => $this->literal( $val ) |
| 499 | }; |
| 500 | break; |
| 501 | |
| 502 | case 'GPSLatitude': |
| 503 | case 'GPSDestLatitude': |
| 504 | $val = $this->formatCoords( $val, 'latitude' ); |
| 505 | break; |
| 506 | case 'GPSLongitude': |
| 507 | case 'GPSDestLongitude': |
| 508 | $val = $this->formatCoords( $val, 'longitude' ); |
| 509 | break; |
| 510 | |
| 511 | case 'GPSSpeedRef': |
| 512 | $val = match ( $val ) { |
| 513 | 'K', 'M', 'N' => $this->exifMsg( 'GPSSpeed', $val ), |
| 514 | /* If not recognized, display as is. */ |
| 515 | default => $this->literal( $val ) |
| 516 | }; |
| 517 | break; |
| 518 | |
| 519 | case 'GPSDestDistanceRef': |
| 520 | $val = match ( $val ) { |
| 521 | 'K', 'M', 'N' => $this->exifMsg( 'GPSDestDistance', $val ), |
| 522 | /* If not recognized, display as is. */ |
| 523 | default => $this->literal( $val ) |
| 524 | }; |
| 525 | break; |
| 526 | |
| 527 | case 'GPSDOP': |
| 528 | // See https://en.wikipedia.org/wiki/Dilution_of_precision_(GPS) |
| 529 | if ( $val <= 2 ) { |
| 530 | $val = $this->exifMsg( $tag, 'excellent', $this->formatNum( $val ) ); |
| 531 | } elseif ( $val <= 5 ) { |
| 532 | $val = $this->exifMsg( $tag, 'good', $this->formatNum( $val ) ); |
| 533 | } elseif ( $val <= 10 ) { |
| 534 | $val = $this->exifMsg( $tag, 'moderate', $this->formatNum( $val ) ); |
| 535 | } elseif ( $val <= 20 ) { |
| 536 | $val = $this->exifMsg( $tag, 'fair', $this->formatNum( $val ) ); |
| 537 | } else { |
| 538 | $val = $this->exifMsg( $tag, 'poor', $this->formatNum( $val ) ); |
| 539 | } |
| 540 | break; |
| 541 | |
| 542 | // This is not in the Exif standard, just a special |
| 543 | // case for our purposes which enables wikis to wikify |
| 544 | // the make, model and software name to link to their articles. |
| 545 | case 'Make': |
| 546 | case 'Model': |
| 547 | $val = $this->exifMsg( $tag, '', $this->literal( $val ) ); |
| 548 | break; |
| 549 | |
| 550 | case 'Software': |
| 551 | if ( is_array( $val ) ) { |
| 552 | if ( count( $val ) > 1 ) { |
| 553 | // if its a software, version array. |
| 554 | $val = $this->msg( |
| 555 | 'exif-software-version-value', |
| 556 | $this->literal( $val[0] ), |
| 557 | $this->literal( $val[1] ) |
| 558 | )->text(); |
| 559 | } else { |
| 560 | // https://phabricator.wikimedia.org/T178130 |
| 561 | $val = $this->exifMsg( $tag, '', $this->literal( $val[0] ) ); |
| 562 | } |
| 563 | } else { |
| 564 | $val = $this->exifMsg( $tag, '', $this->literal( $val ) ); |
| 565 | } |
| 566 | break; |
| 567 | |
| 568 | case 'ExposureTime': |
| 569 | // Show the pretty fraction as well as decimal version |
| 570 | $val = $this->msg( 'exif-exposuretime-format', |
| 571 | $this->formatFraction( $val ), $this->formatNum( $val ) )->text(); |
| 572 | break; |
| 573 | case 'ISOSpeedRatings': |
| 574 | // If it's 65535 that means it's at the |
| 575 | // limit of the size of Exif::short and |
| 576 | // is really higher. |
| 577 | if ( $val === '65535' ) { |
| 578 | $val = $this->exifMsg( $tag, 'overflow' ); |
| 579 | } else { |
| 580 | $val = $this->formatNum( $val ); |
| 581 | } |
| 582 | break; |
| 583 | case 'FNumber': |
| 584 | $val = $this->msg( 'exif-fnumber-format', |
| 585 | $this->formatNum( $val ) )->text(); |
| 586 | break; |
| 587 | |
| 588 | case 'FocalLength': |
| 589 | case 'FocalLengthIn35mmFilm': |
| 590 | $val = $this->msg( 'exif-focallength-format', |
| 591 | $this->formatNum( $val ) )->text(); |
| 592 | break; |
| 593 | |
| 594 | case 'MaxApertureValue': |
| 595 | if ( str_contains( $val, '/' ) ) { |
| 596 | // need to expand this earlier to calculate fNumber |
| 597 | [ $n, $d ] = explode( '/', $val, 2 ); |
| 598 | if ( is_numeric( $n ) && is_numeric( $d ) ) { |
| 599 | $val = (int)$n / (int)$d; |
| 600 | } |
| 601 | } |
| 602 | if ( is_numeric( $val ) ) { |
| 603 | $fNumber = 2 ** ( $val / 2 ); |
| 604 | if ( is_finite( $fNumber ) ) { |
| 605 | $val = $this->msg( 'exif-maxaperturevalue-value', |
| 606 | $this->formatNum( $val ), |
| 607 | $this->formatNum( $fNumber, 2 ) |
| 608 | )->text(); |
| 609 | break; |
| 610 | } |
| 611 | } |
| 612 | $val = $this->literal( $val ); |
| 613 | break; |
| 614 | |
| 615 | case 'iimCategory': |
| 616 | $val = match ( strtolower( $val ) ) { |
| 617 | // See pg 29 of IPTC photo |
| 618 | // metadata standard. |
| 619 | 'ace', 'clj', 'dis', 'fin', 'edu', 'evn', 'hth', 'hum', 'lab', 'lif', |
| 620 | 'pol', 'rel', 'sci', 'soi', 'spo', 'war', |
| 621 | 'wea' => $this->exifMsg( 'iimcategory', $val ), |
| 622 | default => $this->literal( $val ) |
| 623 | }; |
| 624 | break; |
| 625 | case 'SubjectNewsCode': |
| 626 | // Essentially like iimCategory. |
| 627 | // 8 (numeric) digit hierarchical |
| 628 | // classification. We decode the |
| 629 | // first 2 digits, which provide |
| 630 | // a broad category. |
| 631 | $val = $this->convertNewsCode( $val ); |
| 632 | break; |
| 633 | case 'Urgency': |
| 634 | // 1-8 with 1 being highest, 5 normal |
| 635 | // 0 is reserved, and 9 is 'user-defined'. |
| 636 | $urgency = ''; |
| 637 | if ( $val === 0 || $val === 9 ) { |
| 638 | $urgency = 'other'; |
| 639 | } elseif ( $val < 5 && $val > 1 ) { |
| 640 | $urgency = 'high'; |
| 641 | } elseif ( $val === 5 ) { |
| 642 | $urgency = 'normal'; |
| 643 | } elseif ( $val <= 8 && $val > 5 ) { |
| 644 | $urgency = 'low'; |
| 645 | } |
| 646 | |
| 647 | if ( $urgency !== '' ) { |
| 648 | $val = $this->exifMsg( 'urgency', |
| 649 | $urgency, $this->literal( $val ) |
| 650 | ); |
| 651 | } else { |
| 652 | $val = $this->literal( $val ); |
| 653 | } |
| 654 | break; |
| 655 | case 'DigitalSourceType': |
| 656 | // Should be a url starting with |
| 657 | // http://cv.iptc.org/newscodes/digitalsourcetype/ |
| 658 | if ( str_starts_with( $val, 'http://cv.iptc.org/newscodes/digitalsourcetype/' ) ) { |
| 659 | $code = substr( $val, 47 ); |
| 660 | $msg = $this->msg( 'exif-digitalsourcetype-' . strtolower( $code ) ); |
| 661 | if ( !$msg->isDisabled() ) { |
| 662 | $val = $msg->text(); |
| 663 | break; |
| 664 | } |
| 665 | } |
| 666 | $val = $this->literal( $val ); |
| 667 | break; |
| 668 | // Things that have a unit of pixels. |
| 669 | case 'OriginalImageHeight': |
| 670 | case 'OriginalImageWidth': |
| 671 | case 'PixelXDimension': |
| 672 | case 'PixelYDimension': |
| 673 | case 'ImageWidth': |
| 674 | case 'ImageLength': |
| 675 | $val = $this->formatNum( $val ) . ' ' . $this->msg( 'unit-pixel' )->text(); |
| 676 | break; |
| 677 | |
| 678 | // Do not transform fields with pure text. |
| 679 | // For some languages the formatNum() |
| 680 | // conversion results to wrong output like |
| 681 | // foo,bar@example,com or foo٫bar@example٫com. |
| 682 | // Also some 'numeric' things like Scene codes |
| 683 | // are included here as we really don't want |
| 684 | // commas inserted. |
| 685 | case 'ImageDescription': |
| 686 | case 'UserComment': |
| 687 | case 'Artist': |
| 688 | case 'Copyright': |
| 689 | case 'RelatedSoundFile': |
| 690 | case 'ImageUniqueID': |
| 691 | case 'SpectralSensitivity': |
| 692 | case 'GPSSatellites': |
| 693 | case 'GPSVersionID': |
| 694 | case 'GPSMapDatum': |
| 695 | case 'Keywords': |
| 696 | case 'WorldRegionDest': |
| 697 | case 'CountryDest': |
| 698 | case 'CountryCodeDest': |
| 699 | case 'ProvinceOrStateDest': |
| 700 | case 'CityDest': |
| 701 | case 'SublocationDest': |
| 702 | case 'WorldRegionCreated': |
| 703 | case 'CountryCreated': |
| 704 | case 'CountryCodeCreated': |
| 705 | case 'ProvinceOrStateCreated': |
| 706 | case 'CityCreated': |
| 707 | case 'SublocationCreated': |
| 708 | case 'ObjectName': |
| 709 | case 'SpecialInstructions': |
| 710 | case 'Headline': |
| 711 | case 'Credit': |
| 712 | case 'Source': |
| 713 | case 'EditStatus': |
| 714 | case 'FixtureIdentifier': |
| 715 | case 'LocationDest': |
| 716 | case 'LocationDestCode': |
| 717 | case 'Writer': |
| 718 | case 'JPEGFileComment': |
| 719 | case 'iimSupplementalCategory': |
| 720 | case 'OriginalTransmissionRef': |
| 721 | case 'Identifier': |
| 722 | case 'dc-contributor': |
| 723 | case 'dc-coverage': |
| 724 | case 'dc-publisher': |
| 725 | case 'dc-relation': |
| 726 | case 'dc-rights': |
| 727 | case 'dc-source': |
| 728 | case 'dc-type': |
| 729 | case 'Lens': |
| 730 | case 'SerialNumber': |
| 731 | case 'CameraOwnerName': |
| 732 | case 'Label': |
| 733 | case 'Nickname': |
| 734 | case 'RightsCertificate': |
| 735 | case 'CopyrightOwner': |
| 736 | case 'UsageTerms': |
| 737 | case 'WebStatement': |
| 738 | case 'OriginalDocumentID': |
| 739 | case 'LicenseUrl': |
| 740 | case 'MorePermissionsUrl': |
| 741 | case 'AttributionUrl': |
| 742 | case 'PreferredAttributionName': |
| 743 | case 'PNGFileComment': |
| 744 | case 'Disclaimer': |
| 745 | case 'ContentWarning': |
| 746 | case 'GIFFileComment': |
| 747 | case 'SceneCode': |
| 748 | case 'IntellectualGenre': |
| 749 | case 'Event': |
| 750 | case 'OrganisationInImage': |
| 751 | case 'PersonInImage': |
| 752 | case 'CaptureSoftware': |
| 753 | case 'GPSAreaInformation': |
| 754 | case 'GPSProcessingMethod': |
| 755 | case 'StitchingSoftware': |
| 756 | case 'SubSecTime': |
| 757 | case 'SubSecTimeOriginal': |
| 758 | case 'SubSecTimeDigitized': |
| 759 | $val = $this->literal( $val ); |
| 760 | break; |
| 761 | |
| 762 | case 'ProjectionType': |
| 763 | $val = match ( $val ) { |
| 764 | 'equirectangular' => $this->exifMsg( $tag, $val ), |
| 765 | default => $this->literal( $val ) |
| 766 | }; |
| 767 | break; |
| 768 | case 'ObjectCycle': |
| 769 | $val = match ( $val ) { |
| 770 | 'a', 'p', 'b' => $this->exifMsg( $tag, $val ), |
| 771 | default => $this->literal( $val ) |
| 772 | }; |
| 773 | break; |
| 774 | case 'Copyrighted': |
| 775 | case 'UsePanoramaViewer': |
| 776 | case 'ExposureLockUsed': |
| 777 | $val = match ( $val ) { |
| 778 | 'True', 'False' => $this->exifMsg( $tag, $val ), |
| 779 | default => $this->literal( $val ) |
| 780 | }; |
| 781 | break; |
| 782 | case 'Rating': |
| 783 | if ( $val === '-1' ) { |
| 784 | $val = $this->exifMsg( $tag, 'rejected' ); |
| 785 | } else { |
| 786 | $val = $this->formatNum( $val ); |
| 787 | } |
| 788 | break; |
| 789 | |
| 790 | case 'LanguageCode': |
| 791 | $lang = MediaWikiServices::getInstance() |
| 792 | ->getLanguageNameUtils() |
| 793 | ->getLanguageName( strtolower( $val ), $this->getLanguage()->getCode() ); |
| 794 | $val = $this->literal( $lang ?: $val ); |
| 795 | break; |
| 796 | |
| 797 | default: |
| 798 | $val = $this->formatNum( $val, false, $tag ); |
| 799 | break; |
| 800 | } |
| 801 | } |
| 802 | // End formatting values, start flattening arrays. |
| 803 | $vals = $this->flattenArrayReal( $vals, $type ); |
| 804 | } |
| 805 | |
| 806 | return $tags; |
| 807 | } |
| 808 | |
| 809 | /** |
| 810 | * A function to collapse multivalued tags into a single value. |
| 811 | * This turns an array of (for example) authors into a bulleted list. |
| 812 | * |
| 813 | * This is public on the basis it might be useful outside of this class. |
| 814 | * |
| 815 | * @param array $vals Array of values |
| 816 | * @param string $type Type of array (either lang, ul, ol). |
| 817 | * lang = language assoc array with keys being the lang code |
| 818 | * ul = unordered list, ol = ordered list |
| 819 | * type can also come from the '_type' member of $vals. |
| 820 | * @param bool $noHtml If to avoid returning anything resembling HTML. |
| 821 | * (Ugly hack for backwards compatibility with old mediawiki). |
| 822 | * @return string Single value (in wiki-syntax). |
| 823 | * @since 1.23 |
| 824 | * @internal |
| 825 | */ |
| 826 | public function flattenArrayReal( $vals, $type = 'ul', $noHtml = false ) { |
| 827 | if ( !is_array( $vals ) ) { |
| 828 | return $vals; // do nothing if not an array; |
| 829 | } |
| 830 | |
| 831 | if ( isset( $vals['_type'] ) ) { |
| 832 | $type = $vals['_type']; |
| 833 | unset( $vals['_type'] ); |
| 834 | } |
| 835 | |
| 836 | if ( count( $vals ) === 1 && $type !== 'lang' && isset( $vals[0] ) ) { |
| 837 | return $vals[0]; |
| 838 | } |
| 839 | if ( count( $vals ) === 0 ) { |
| 840 | wfDebug( __METHOD__ . " metadata array with 0 elements!" ); |
| 841 | |
| 842 | return ""; // paranoia. This should never happen |
| 843 | } |
| 844 | // Check if $vals contains nested arrays |
| 845 | $containsNestedArrays = in_array( true, array_map( 'is_array', $vals ), true ); |
| 846 | if ( $containsNestedArrays ) { |
| 847 | wfLogWarning( __METHOD__ . ': Invalid $vals, contains nested arrays: ' . json_encode( $vals ) ); |
| 848 | } |
| 849 | |
| 850 | /* @todo FIXME: This should hide some of the list entries if there are |
| 851 | * say more than four. Especially if a field is translated into 20 |
| 852 | * languages, we don't want to show them all by default |
| 853 | */ |
| 854 | switch ( $type ) { |
| 855 | case 'lang': |
| 856 | // Display default, followed by ContentLanguage, |
| 857 | // followed by the rest in no particular order. |
| 858 | |
| 859 | // Todo: hide some items if really long list. |
| 860 | |
| 861 | $content = ''; |
| 862 | |
| 863 | $priorityLanguages = $this->getPriorityLanguages(); |
| 864 | $defaultItem = false; |
| 865 | $defaultLang = false; |
| 866 | |
| 867 | // If default is set, save it for later, |
| 868 | // as we don't know if it's equal to one of the lang codes. |
| 869 | // (In xmp you specify the language for a default property by having |
| 870 | // both a default prop, and one in the language that are identical) |
| 871 | if ( isset( $vals['x-default'] ) ) { |
| 872 | $defaultItem = $vals['x-default']; |
| 873 | unset( $vals['x-default'] ); |
| 874 | } |
| 875 | foreach ( $priorityLanguages as $pLang ) { |
| 876 | if ( isset( $vals[$pLang] ) ) { |
| 877 | $isDefault = false; |
| 878 | if ( $vals[$pLang] === $defaultItem ) { |
| 879 | $defaultItem = false; |
| 880 | $isDefault = true; |
| 881 | } |
| 882 | $content .= $this->langItem( $vals[$pLang], $pLang, $isDefault, $noHtml ); |
| 883 | |
| 884 | unset( $vals[$pLang] ); |
| 885 | |
| 886 | if ( $this->singleLang ) { |
| 887 | return Html::rawElement( 'span', [ 'lang' => $pLang ], $vals[$pLang] ); |
| 888 | } |
| 889 | } |
| 890 | } |
| 891 | |
| 892 | // Now do the rest. |
| 893 | foreach ( $vals as $lang => $item ) { |
| 894 | if ( $item === $defaultItem ) { |
| 895 | $defaultLang = $lang; |
| 896 | continue; |
| 897 | } |
| 898 | $content .= $this->langItem( $item, $lang, false, $noHtml ); |
| 899 | if ( $this->singleLang ) { |
| 900 | return Html::rawElement( 'span', [ 'lang' => $lang ], $item ); |
| 901 | } |
| 902 | } |
| 903 | if ( $defaultItem !== false ) { |
| 904 | $content = $this->langItem( $defaultItem, $defaultLang, true, $noHtml ) . $content; |
| 905 | if ( $this->singleLang ) { |
| 906 | return $defaultItem; |
| 907 | } |
| 908 | } |
| 909 | if ( $noHtml ) { |
| 910 | return $content; |
| 911 | } |
| 912 | |
| 913 | return '<ul class="metadata-langlist">' . $content . '</ul>'; |
| 914 | case 'ol': |
| 915 | if ( $noHtml ) { |
| 916 | return "\n#" . implode( "\n#", $vals ); |
| 917 | } |
| 918 | |
| 919 | return "<ol><li>" . implode( "</li>\n<li>", $vals ) . '</li></ol>'; |
| 920 | case 'ul': |
| 921 | default: |
| 922 | if ( $noHtml ) { |
| 923 | return "\n*" . implode( "\n*", $vals ); |
| 924 | } |
| 925 | |
| 926 | return "<ul><li>" . implode( "</li>\n<li>", $vals ) . '</li></ul>'; |
| 927 | } |
| 928 | } |
| 929 | |
| 930 | /** Helper function for creating lists of translations. |
| 931 | * |
| 932 | * @param string $value Value (this is not escaped) |
| 933 | * @param string $lang Lang code of item or false |
| 934 | * @param bool $default If it is default value. |
| 935 | * @param bool $noHtml If to avoid html (for back-compat) |
| 936 | * @return string Language item (Note: despite how this looks, this is |
| 937 | * treated as wikitext, not as HTML). |
| 938 | */ |
| 939 | private function langItem( $value, $lang, $default = false, $noHtml = false ) { |
| 940 | if ( $lang === false && $default === false ) { |
| 941 | throw new InvalidArgumentException( '$lang and $default cannot both be false.' ); |
| 942 | } |
| 943 | |
| 944 | if ( $noHtml ) { |
| 945 | $wrappedValue = $this->literal( $value ); |
| 946 | } else { |
| 947 | $wrappedValue = '<span class="mw-metadata-lang-value">' . $this->literal( $value ) . '</span>'; |
| 948 | } |
| 949 | |
| 950 | if ( $lang === false ) { |
| 951 | $msg = $this->msg( 'metadata-langitem-default', $wrappedValue ); |
| 952 | if ( $noHtml ) { |
| 953 | return $msg->text() . "\n\n"; |
| 954 | } /* else */ |
| 955 | |
| 956 | return '<li class="mw-metadata-lang-default">' . $msg->text() . "</li>\n"; |
| 957 | } |
| 958 | |
| 959 | $lowLang = strtolower( $lang ); |
| 960 | $languageNameUtils = MediaWikiServices::getInstance()->getLanguageNameUtils(); |
| 961 | $langName = $languageNameUtils->getLanguageName( $lowLang ); |
| 962 | if ( $langName === '' ) { |
| 963 | // try just the base language name. (aka en-US -> en ). |
| 964 | $langPrefix = explode( '-', $lowLang, 2 )[0]; |
| 965 | $langName = $languageNameUtils->getLanguageName( $langPrefix ); |
| 966 | if ( $langName === '' ) { |
| 967 | // give up. |
| 968 | $langName = $lang; |
| 969 | } |
| 970 | } |
| 971 | // else we have a language specified |
| 972 | |
| 973 | $msg = $this->msg( 'metadata-langitem', $wrappedValue, $langName, $lang ); |
| 974 | if ( $noHtml ) { |
| 975 | return '*' . $msg->text(); |
| 976 | } /* else: */ |
| 977 | |
| 978 | $item = '<li class="mw-metadata-lang-code-' . $lang; |
| 979 | if ( $default ) { |
| 980 | $item .= ' mw-metadata-lang-default'; |
| 981 | } |
| 982 | $item .= '" lang="' . $lang . '">'; |
| 983 | $item .= $msg->text(); |
| 984 | $item .= "</li>\n"; |
| 985 | |
| 986 | return $item; |
| 987 | } |
| 988 | |
| 989 | /** |
| 990 | * Convenience function for getFormattedData() |
| 991 | * |
| 992 | * @param string|int|null $val The literal value |
| 993 | * @return string The value, properly escaped as wikitext -- with some |
| 994 | * exceptions to allow auto-linking, etc. |
| 995 | */ |
| 996 | protected function literal( $val ): string { |
| 997 | if ( $val === null ) { |
| 998 | return ''; |
| 999 | } |
| 1000 | // T266707: historically this has used htmlspecialchars to protect |
| 1001 | // the string contents, but it should probably be changed to use |
| 1002 | // wfEscapeWikitext() instead -- however, "we still want to auto-link |
| 1003 | // urls" so wfEscapeWikitext isn't *quite* right... |
| 1004 | return htmlspecialchars( $val ); |
| 1005 | } |
| 1006 | |
| 1007 | /** |
| 1008 | * Convenience function for getFormattedData() |
| 1009 | * |
| 1010 | * @param string $tag The tag name to pass on |
| 1011 | * @param string|int $val The value of the tag |
| 1012 | * @param string|null $arg A wikitext argument to pass ($1) |
| 1013 | * @param string|null $arg2 A 2nd wikitext argument to pass ($2) |
| 1014 | * @return string The text content of "exif-$tag-$val" message in lower case |
| 1015 | */ |
| 1016 | private function exifMsg( $tag, $val, $arg = null, $arg2 = null ) { |
| 1017 | if ( $val === '' ) { |
| 1018 | $val = 'value'; |
| 1019 | } |
| 1020 | |
| 1021 | return $this->msg( |
| 1022 | MediaWikiServices::getInstance()->getContentLanguage()->lc( "exif-$tag-$val" ), |
| 1023 | $arg, |
| 1024 | $arg2 |
| 1025 | )->text(); |
| 1026 | } |
| 1027 | |
| 1028 | /** |
| 1029 | * Format a number, convert numbers from fractions into floating point |
| 1030 | * numbers, joins arrays of numbers with commas. |
| 1031 | * |
| 1032 | * @param mixed $num The value to format |
| 1033 | * @param float|int|false $round Digits to round to or false. |
| 1034 | * @param string|null $tagName (optional) The name of the tag (for debugging) |
| 1035 | * @return mixed A floating point number or whatever we were fed |
| 1036 | */ |
| 1037 | private function formatNum( $num, $round = false, $tagName = null ) { |
| 1038 | $m = []; |
| 1039 | if ( is_array( $num ) ) { |
| 1040 | $out = []; |
| 1041 | foreach ( $num as $number ) { |
| 1042 | $out[] = $this->formatNum( $number, $round, $tagName ); |
| 1043 | } |
| 1044 | |
| 1045 | return $this->getLanguage()->commaList( $out ); |
| 1046 | } |
| 1047 | if ( is_numeric( $num ) ) { |
| 1048 | if ( $round !== false ) { |
| 1049 | $num = round( $num, $round ); |
| 1050 | } |
| 1051 | return $this->getLanguage()->formatNum( $num ); |
| 1052 | } |
| 1053 | $num ??= ''; |
| 1054 | if ( preg_match( '/^(-?\d+)\/(\d+)$/', $num, $m ) ) { |
| 1055 | if ( $m[2] !== 0 ) { |
| 1056 | $newNum = (int)$m[1] / (int)$m[2]; |
| 1057 | if ( $round !== false ) { |
| 1058 | $newNum = round( $newNum, $round ); |
| 1059 | } |
| 1060 | } else { |
| 1061 | $newNum = $num; |
| 1062 | } |
| 1063 | |
| 1064 | return $this->getLanguage()->formatNum( $newNum ); |
| 1065 | } |
| 1066 | # T267370: there are a lot of strange EXIF tags floating around. |
| 1067 | LoggerFactory::getInstance( 'formatnum' )->warning( |
| 1068 | 'FormatMetadata::formatNum with non-numeric value', |
| 1069 | [ |
| 1070 | 'tag' => $tagName, |
| 1071 | 'value' => $num, |
| 1072 | ] |
| 1073 | ); |
| 1074 | return $this->literal( $num ); |
| 1075 | } |
| 1076 | |
| 1077 | /** |
| 1078 | * Format a rational number, reducing fractions |
| 1079 | * |
| 1080 | * @param mixed $num The value to format |
| 1081 | * @return mixed A floating point number or whatever we were fed |
| 1082 | */ |
| 1083 | private function formatFraction( $num ) { |
| 1084 | $m = []; |
| 1085 | $num ??= ''; |
| 1086 | if ( preg_match( '/^(-?\d+)\/(\d+)$/', $num, $m ) ) { |
| 1087 | $numerator = (int)$m[1]; |
| 1088 | $denominator = (int)$m[2]; |
| 1089 | $gcd = $this->gcd( abs( $numerator ), $denominator ); |
| 1090 | if ( $gcd !== 0 ) { |
| 1091 | // 0 shouldn't happen! ;) |
| 1092 | return $this->formatNum( $numerator / $gcd ) . '/' . $this->formatNum( $denominator / $gcd ); |
| 1093 | } |
| 1094 | } |
| 1095 | |
| 1096 | return $this->formatNum( $num ); |
| 1097 | } |
| 1098 | |
| 1099 | /** |
| 1100 | * Calculate the greatest common divisor of two integers. |
| 1101 | * |
| 1102 | * @param int $a Numerator |
| 1103 | * @param int $b Denominator |
| 1104 | * @return int |
| 1105 | */ |
| 1106 | private function gcd( $a, $b ) { |
| 1107 | /* |
| 1108 | // https://en.wikipedia.org/wiki/Euclidean_algorithm |
| 1109 | // Recursive form would be: |
| 1110 | if ( $b == 0 ) |
| 1111 | return $a; |
| 1112 | else |
| 1113 | return gcd( $b, $a % $b ); |
| 1114 | */ |
| 1115 | while ( $b != 0 ) { |
| 1116 | $remainder = $a % $b; |
| 1117 | |
| 1118 | // tail recursion... |
| 1119 | $a = $b; |
| 1120 | $b = $remainder; |
| 1121 | } |
| 1122 | |
| 1123 | return $a; |
| 1124 | } |
| 1125 | |
| 1126 | /** |
| 1127 | * Fetch the human readable version of a news code. |
| 1128 | * A news code is an 8 digit code. The first two |
| 1129 | * digits are a general classification, so we just |
| 1130 | * translate that. |
| 1131 | * |
| 1132 | * Note, leading 0's are significant, so this is |
| 1133 | * a string, not an int. |
| 1134 | * |
| 1135 | * @param string $val The 8 digit news code. |
| 1136 | * @return string The human readable form |
| 1137 | */ |
| 1138 | private function convertNewsCode( $val ) { |
| 1139 | if ( !preg_match( '/^\d{8}$/D', $val ) ) { |
| 1140 | // Not a valid news code. |
| 1141 | return $val; |
| 1142 | } |
| 1143 | $cat = match ( substr( $val, 0, 2 ) ) { |
| 1144 | '01' => 'ace', |
| 1145 | '02' => 'clj', |
| 1146 | '03' => 'dis', |
| 1147 | '04' => 'fin', |
| 1148 | '05' => 'edu', |
| 1149 | '06' => 'evn', |
| 1150 | '07' => 'hth', |
| 1151 | '08' => 'hum', |
| 1152 | '09' => 'lab', |
| 1153 | '10' => 'lif', |
| 1154 | '11' => 'pol', |
| 1155 | '12' => 'rel', |
| 1156 | '13' => 'sci', |
| 1157 | '14' => 'soi', |
| 1158 | '15' => 'spo', |
| 1159 | '16' => 'war', |
| 1160 | '17' => 'wea', |
| 1161 | default => '', |
| 1162 | }; |
| 1163 | if ( $cat !== '' ) { |
| 1164 | $catMsg = $this->exifMsg( 'iimcategory', $cat ); |
| 1165 | $val = $this->exifMsg( 'subjectnewscode', '', $this->literal( $val ), $catMsg ); |
| 1166 | } |
| 1167 | |
| 1168 | return $val; |
| 1169 | } |
| 1170 | |
| 1171 | /** |
| 1172 | * Format a coordinate value, convert numbers from floating point |
| 1173 | * into degree minute second representation. |
| 1174 | * |
| 1175 | * @param float|string $coord Expected to be a number or numeric string in degrees |
| 1176 | * @param string $type "latitude" or "longitude" |
| 1177 | * @return string |
| 1178 | */ |
| 1179 | private function formatCoords( $coord, string $type ) { |
| 1180 | if ( !is_numeric( $coord ) ) { |
| 1181 | wfDebugLog( 'exif', __METHOD__ . ": \"$coord\" is not a number" ); |
| 1182 | return $this->literal( (string)$coord ); |
| 1183 | } |
| 1184 | |
| 1185 | $ref = ''; |
| 1186 | if ( $coord < 0 ) { |
| 1187 | $nCoord = -$coord; |
| 1188 | if ( $type === 'latitude' ) { |
| 1189 | $ref = 'S'; |
| 1190 | } elseif ( $type === 'longitude' ) { |
| 1191 | $ref = 'W'; |
| 1192 | } |
| 1193 | } else { |
| 1194 | $nCoord = (float)$coord; |
| 1195 | if ( $type === 'latitude' ) { |
| 1196 | $ref = 'N'; |
| 1197 | } elseif ( $type === 'longitude' ) { |
| 1198 | $ref = 'E'; |
| 1199 | } |
| 1200 | } |
| 1201 | |
| 1202 | $deg = floor( $nCoord ); |
| 1203 | $min = floor( ( $nCoord - $deg ) * 60 ); |
| 1204 | $sec = round( ( ( $nCoord - $deg ) * 60 - $min ) * 60, 2 ); |
| 1205 | |
| 1206 | $deg = $this->formatNum( $deg ); |
| 1207 | $min = $this->formatNum( $min ); |
| 1208 | $sec = $this->formatNum( $sec ); |
| 1209 | |
| 1210 | // Note the default message "$1° $2′ $3″ $4" ignores the 5th parameter |
| 1211 | return $this->msg( 'exif-coordinate-format', $deg, $min, $sec, $ref, $this->literal( $coord ) )->text(); |
| 1212 | } |
| 1213 | |
| 1214 | /** |
| 1215 | * Format the contact info field into a single value. |
| 1216 | * |
| 1217 | * This function might be called from |
| 1218 | * ExifBitmapHandler::convertMetadataVersion which is why it is |
| 1219 | * public. |
| 1220 | * |
| 1221 | * @param array $vals Array with fields of the ContactInfo |
| 1222 | * struct defined in the IPTC4XMP spec. Or potentially |
| 1223 | * an array with one element that is a free form text |
| 1224 | * value from the older iptc iim 1:118 prop. |
| 1225 | * @return string HTML-ish looking wikitext |
| 1226 | * @since 1.23 no longer static |
| 1227 | */ |
| 1228 | public function collapseContactInfo( array $vals ) { |
| 1229 | if ( !( isset( $vals['CiAdrExtadr'] ) |
| 1230 | || isset( $vals['CiAdrCity'] ) |
| 1231 | || isset( $vals['CiAdrCtry'] ) |
| 1232 | || isset( $vals['CiEmailWork'] ) |
| 1233 | || isset( $vals['CiTelWork'] ) |
| 1234 | || isset( $vals['CiAdrPcode'] ) |
| 1235 | || isset( $vals['CiAdrRegion'] ) |
| 1236 | || isset( $vals['CiUrlWork'] ) |
| 1237 | ) ) { |
| 1238 | // We don't have any sub-properties |
| 1239 | // This could happen if its using old |
| 1240 | // iptc that just had this as a free-form |
| 1241 | // text value. |
| 1242 | // Note: people often insert >, etc into |
| 1243 | // the metadata which should not be interpreted |
| 1244 | // but we still want to auto-link urls. |
| 1245 | foreach ( $vals as &$val ) { |
| 1246 | $val = $this->literal( $val ); |
| 1247 | } |
| 1248 | |
| 1249 | return $this->flattenArrayReal( $vals ); |
| 1250 | } |
| 1251 | |
| 1252 | // We have a real ContactInfo field. |
| 1253 | // Its unclear if all these fields have to be |
| 1254 | // set, so assume they do not. |
| 1255 | $url = $tel = $street = $city = $country = ''; |
| 1256 | $email = $postal = $region = ''; |
| 1257 | |
| 1258 | // Also note, some of the class names this uses |
| 1259 | // are similar to those used by hCard. This is |
| 1260 | // mostly because they're sensible names. This |
| 1261 | // does not (and does not attempt to) output |
| 1262 | // stuff in the hCard microformat. However it |
| 1263 | // might output in the adr microformat. |
| 1264 | |
| 1265 | if ( isset( $vals['CiAdrExtadr'] ) ) { |
| 1266 | // Todo: This can potentially be multi-line. |
| 1267 | // Need to check how that works in XMP. |
| 1268 | $street = '<span class="extended-address">' |
| 1269 | . $this->literal( |
| 1270 | $vals['CiAdrExtadr'] ) |
| 1271 | . '</span>'; |
| 1272 | } |
| 1273 | if ( isset( $vals['CiAdrCity'] ) ) { |
| 1274 | $city = '<span class="locality">' |
| 1275 | . $this->literal( $vals['CiAdrCity'] ) |
| 1276 | . '</span>'; |
| 1277 | } |
| 1278 | if ( isset( $vals['CiAdrCtry'] ) ) { |
| 1279 | $country = '<span class="country-name">' |
| 1280 | . $this->literal( $vals['CiAdrCtry'] ) |
| 1281 | . '</span>'; |
| 1282 | } |
| 1283 | if ( isset( $vals['CiEmailWork'] ) ) { |
| 1284 | $emails = []; |
| 1285 | // Have to split multiple emails at commas/new lines. |
| 1286 | $splitEmails = explode( "\n", $vals['CiEmailWork'] ); |
| 1287 | foreach ( $splitEmails as $e1 ) { |
| 1288 | // Also split on comma |
| 1289 | foreach ( explode( ',', $e1 ) as $e2 ) { |
| 1290 | $finalEmail = trim( $e2 ); |
| 1291 | if ( $finalEmail === ',' || $finalEmail === '' ) { |
| 1292 | continue; |
| 1293 | } |
| 1294 | if ( str_contains( $finalEmail, '<' ) ) { |
| 1295 | // Don't do fancy formatting to |
| 1296 | // "My name" <foo@bar.com> style stuff |
| 1297 | $emails[] = $this->literal( $finalEmail ); |
| 1298 | } else { |
| 1299 | $emails[] = '[mailto:' |
| 1300 | . $finalEmail |
| 1301 | . ' <span class="email">' |
| 1302 | . $this->literal( $finalEmail ) |
| 1303 | . '</span>]'; |
| 1304 | } |
| 1305 | } |
| 1306 | } |
| 1307 | $email = implode( ', ', $emails ); |
| 1308 | } |
| 1309 | if ( isset( $vals['CiTelWork'] ) ) { |
| 1310 | $tel = '<span class="tel">' |
| 1311 | . $this->literal( $vals['CiTelWork'] ) |
| 1312 | . '</span>'; |
| 1313 | } |
| 1314 | if ( isset( $vals['CiAdrPcode'] ) ) { |
| 1315 | $postal = '<span class="postal-code">' |
| 1316 | . $this->literal( $vals['CiAdrPcode'] ) |
| 1317 | . '</span>'; |
| 1318 | } |
| 1319 | if ( isset( $vals['CiAdrRegion'] ) ) { |
| 1320 | // Note this is province/state. |
| 1321 | $region = '<span class="region">' |
| 1322 | . $this->literal( $vals['CiAdrRegion'] ) |
| 1323 | . '</span>'; |
| 1324 | } |
| 1325 | if ( isset( $vals['CiUrlWork'] ) ) { |
| 1326 | $url = '<span class="url">' |
| 1327 | . $this->literal( $vals['CiUrlWork'] ) |
| 1328 | . '</span>'; |
| 1329 | } |
| 1330 | |
| 1331 | return $this->msg( 'exif-contact-value', $email, $url, |
| 1332 | $street, $city, $region, $postal, $country, $tel )->text(); |
| 1333 | } |
| 1334 | |
| 1335 | /** |
| 1336 | * Get a list of fields that are visible by default. |
| 1337 | * |
| 1338 | * @return string[] |
| 1339 | * @since 1.23 |
| 1340 | */ |
| 1341 | public static function getVisibleFields() { |
| 1342 | $fields = []; |
| 1343 | $lines = explode( "\n", wfMessage( 'metadata-fields' )->inContentLanguage()->text() ); |
| 1344 | foreach ( $lines as $line ) { |
| 1345 | $matches = []; |
| 1346 | if ( preg_match( '/^\\*\s*(.*?)\s*$/', $line, $matches ) ) { |
| 1347 | $fields[] = $matches[1]; |
| 1348 | } |
| 1349 | } |
| 1350 | $fields = array_map( 'strtolower', $fields ); |
| 1351 | |
| 1352 | return $fields; |
| 1353 | } |
| 1354 | |
| 1355 | /** |
| 1356 | * Get an array of extended metadata. (See the imageinfo API for format.) |
| 1357 | * |
| 1358 | * @param File $file File to use |
| 1359 | * @return array [<property name> => ['value' => <value>]], or [] on error |
| 1360 | * @since 1.23 |
| 1361 | */ |
| 1362 | public function fetchExtendedMetadata( File $file ) { |
| 1363 | $cache = MediaWikiServices::getInstance()->getMainWANObjectCache(); |
| 1364 | |
| 1365 | // If revision deleted, exit immediately |
| 1366 | if ( $file->isDeleted( File::DELETED_FILE ) ) { |
| 1367 | return []; |
| 1368 | } |
| 1369 | |
| 1370 | $cacheKey = $cache->makeKey( |
| 1371 | 'getExtendedMetadata', |
| 1372 | $this->getLanguage()->getCode(), |
| 1373 | (int)$this->singleLang, |
| 1374 | $file->getSha1() |
| 1375 | ); |
| 1376 | $maxCacheTime = ( $file instanceof ForeignAPIFile ) ? 60 * 60 * 12 : 60 * 60 * 24 * 30; |
| 1377 | |
| 1378 | $cachedValue = $cache->getWithSetCallback( |
| 1379 | $cacheKey, |
| 1380 | $maxCacheTime, |
| 1381 | function () use ( $file ) { |
| 1382 | $fileMetadata = $this->getExtendedMetadataFromFile( $file ); |
| 1383 | $extendedMetadata = $this->getExtendedMetadataFromHook( $file, $fileMetadata, $maxCacheTime ); |
| 1384 | if ( $this->singleLang ) { |
| 1385 | $this->resolveMultilangMetadata( $extendedMetadata ); |
| 1386 | } |
| 1387 | $this->discardMultipleValues( $extendedMetadata ); |
| 1388 | // Make sure the metadata won't break the API when an XML format is used. |
| 1389 | // This is an API-specific function so it would be cleaner to call it from |
| 1390 | // outside fetchExtendedMetadata, but this way we don't need to redo the |
| 1391 | // computation on a cache hit. |
| 1392 | $this->sanitizeArrayForAPI( $extendedMetadata ); |
| 1393 | |
| 1394 | return [ 'data' => $extendedMetadata, 'timestamp' => wfTimestampNow() ]; |
| 1395 | }, |
| 1396 | [ |
| 1397 | 'touchedCallback' => function ( $value ) use ( $file ) { |
| 1398 | if ( |
| 1399 | !$this->getHookRunner()->onValidateExtendedMetadataCache( $value['timestamp'], $file ) |
| 1400 | ) { |
| 1401 | // Reject cache and regenerate |
| 1402 | return time(); |
| 1403 | } |
| 1404 | return null; |
| 1405 | } |
| 1406 | ] |
| 1407 | ); |
| 1408 | |
| 1409 | return $cachedValue['data']; |
| 1410 | } |
| 1411 | |
| 1412 | /** |
| 1413 | * Get file-based metadata in standardized format. |
| 1414 | * |
| 1415 | * Note that for a remote file, this might return metadata supplied by extensions. |
| 1416 | * |
| 1417 | * @param File $file File to use |
| 1418 | * @return array [<property name> => ['value' => <value>]], or [] on error |
| 1419 | * @since 1.23 |
| 1420 | */ |
| 1421 | protected function getExtendedMetadataFromFile( File $file ) { |
| 1422 | // If this is a remote file accessed via an API request, we already |
| 1423 | // have remote metadata so we just ignore any local one |
| 1424 | if ( $file instanceof ForeignAPIFile ) { |
| 1425 | // In case of error we pretend no metadata - this will get cached. |
| 1426 | // Might or might not be a good idea. |
| 1427 | return $file->getExtendedMetadata() ?: []; |
| 1428 | } |
| 1429 | |
| 1430 | $uploadDate = wfTimestamp( TS::ISO_8601, $file->getTimestamp() ); |
| 1431 | |
| 1432 | $fileMetadata = [ |
| 1433 | // This is modification time, which is close to "upload" time. |
| 1434 | 'DateTime' => [ |
| 1435 | 'value' => $uploadDate, |
| 1436 | 'source' => 'mediawiki-metadata', |
| 1437 | ], |
| 1438 | ]; |
| 1439 | |
| 1440 | $title = $file->getTitle(); |
| 1441 | if ( $title ) { |
| 1442 | $text = $title->getText(); |
| 1443 | $pos = strrpos( $text, '.' ); |
| 1444 | |
| 1445 | if ( $pos ) { |
| 1446 | $name = substr( $text, 0, $pos ); |
| 1447 | } else { |
| 1448 | $name = $text; |
| 1449 | } |
| 1450 | |
| 1451 | $fileMetadata['ObjectName'] = [ |
| 1452 | 'value' => $name, |
| 1453 | 'source' => 'mediawiki-metadata', |
| 1454 | ]; |
| 1455 | } |
| 1456 | |
| 1457 | return $fileMetadata; |
| 1458 | } |
| 1459 | |
| 1460 | /** |
| 1461 | * Get additional metadata from hooks in standardized format. |
| 1462 | * |
| 1463 | * @param File $file File to use |
| 1464 | * @param array $extendedMetadata |
| 1465 | * @param int &$maxCacheTime Hook handlers might use this parameter to override cache time |
| 1466 | * |
| 1467 | * @return array [<property name> => ['value' => <value>]], or [] on error |
| 1468 | * @since 1.23 |
| 1469 | */ |
| 1470 | protected function getExtendedMetadataFromHook( File $file, array $extendedMetadata, |
| 1471 | &$maxCacheTime |
| 1472 | ) { |
| 1473 | $this->getHookRunner()->onGetExtendedMetadata( |
| 1474 | $extendedMetadata, |
| 1475 | $file, |
| 1476 | $this->getContext(), |
| 1477 | $this->singleLang, |
| 1478 | $maxCacheTime |
| 1479 | ); |
| 1480 | |
| 1481 | $visible = array_fill_keys( self::getVisibleFields(), true ); |
| 1482 | foreach ( $extendedMetadata as $key => $value ) { |
| 1483 | if ( !isset( $visible[strtolower( $key )] ) ) { |
| 1484 | $extendedMetadata[$key]['hidden'] = ''; |
| 1485 | } |
| 1486 | } |
| 1487 | |
| 1488 | return $extendedMetadata; |
| 1489 | } |
| 1490 | |
| 1491 | /** |
| 1492 | * Turns an XMP-style multilang array into a single value. |
| 1493 | * If the value is not a multilang array, it is returned unchanged. |
| 1494 | * See mediawiki.org/wiki/Manual:File_metadata_handling#Multi-language_array_format |
| 1495 | * @param mixed $value |
| 1496 | * @return mixed Value in best language, null if there were no languages at all |
| 1497 | * @since 1.23 |
| 1498 | */ |
| 1499 | protected function resolveMultilangValue( $value ) { |
| 1500 | if ( |
| 1501 | !is_array( $value ) |
| 1502 | || !isset( $value['_type'] ) |
| 1503 | || $value['_type'] !== 'lang' |
| 1504 | ) { |
| 1505 | return $value; // do nothing if not a multilang array |
| 1506 | } |
| 1507 | |
| 1508 | // choose the language best matching user or site settings |
| 1509 | $priorityLanguages = $this->getPriorityLanguages(); |
| 1510 | foreach ( $priorityLanguages as $lang ) { |
| 1511 | if ( isset( $value[$lang] ) ) { |
| 1512 | return $value[$lang]; |
| 1513 | } |
| 1514 | } |
| 1515 | |
| 1516 | // otherwise go with the default language, if set |
| 1517 | if ( isset( $value['x-default'] ) ) { |
| 1518 | return $value['x-default']; |
| 1519 | } |
| 1520 | |
| 1521 | // otherwise just return any one language |
| 1522 | unset( $value['_type'] ); |
| 1523 | if ( $value ) { |
| 1524 | return reset( $value ); |
| 1525 | } |
| 1526 | |
| 1527 | // this should not happen; signal error |
| 1528 | return null; |
| 1529 | } |
| 1530 | |
| 1531 | /** |
| 1532 | * Turns an XMP-style multivalue array into a single value by dropping all but the first |
| 1533 | * value. If the value is not a multivalue array (or a multivalue array inside a multilang |
| 1534 | * array), it is returned unchanged. |
| 1535 | * See mediawiki.org/wiki/Manual:File_metadata_handling#Multi-language_array_format |
| 1536 | * @param mixed $value |
| 1537 | * @return mixed The value, or the first value if there were multiple ones |
| 1538 | * @since 1.25 |
| 1539 | */ |
| 1540 | protected function resolveMultivalueValue( $value ) { |
| 1541 | if ( !is_array( $value ) ) { |
| 1542 | return $value; |
| 1543 | } |
| 1544 | if ( isset( $value['_type'] ) && $value['_type'] === 'lang' ) { |
| 1545 | // if this is a multilang array, process fields separately |
| 1546 | $newValue = []; |
| 1547 | foreach ( $value as $k => $v ) { |
| 1548 | $newValue[$k] = $this->resolveMultivalueValue( $v ); |
| 1549 | } |
| 1550 | return $newValue; |
| 1551 | } |
| 1552 | // _type is 'ul' or 'ol' or missing in which case it defaults to 'ul' |
| 1553 | $v = reset( $value ); |
| 1554 | if ( key( $value ) === '_type' ) { |
| 1555 | $v = next( $value ); |
| 1556 | } |
| 1557 | return $v; |
| 1558 | } |
| 1559 | |
| 1560 | /** |
| 1561 | * Takes an array returned by the getExtendedMetadata* functions, |
| 1562 | * and resolves multi-language values in it. |
| 1563 | * @param array &$metadata |
| 1564 | * @since 1.23 |
| 1565 | */ |
| 1566 | protected function resolveMultilangMetadata( &$metadata ) { |
| 1567 | if ( !is_array( $metadata ) ) { |
| 1568 | return; |
| 1569 | } |
| 1570 | foreach ( $metadata as &$field ) { |
| 1571 | if ( isset( $field['value'] ) ) { |
| 1572 | $field['value'] = $this->resolveMultilangValue( $field['value'] ); |
| 1573 | } |
| 1574 | } |
| 1575 | } |
| 1576 | |
| 1577 | /** |
| 1578 | * Takes an array returned by the getExtendedMetadata* functions, |
| 1579 | * and turns all fields into single-valued ones by dropping extra values. |
| 1580 | * @param array &$metadata |
| 1581 | * @since 1.25 |
| 1582 | */ |
| 1583 | protected function discardMultipleValues( &$metadata ) { |
| 1584 | if ( !is_array( $metadata ) ) { |
| 1585 | return; |
| 1586 | } |
| 1587 | foreach ( $metadata as $key => &$field ) { |
| 1588 | if ( $key === 'Software' || $key === 'Contact' ) { |
| 1589 | // we skip some fields which have composite values. They are not particularly interesting |
| 1590 | // and you can get them via the metadata / commonmetadata APIs anyway. |
| 1591 | continue; |
| 1592 | } |
| 1593 | if ( isset( $field['value'] ) ) { |
| 1594 | $field['value'] = $this->resolveMultivalueValue( $field['value'] ); |
| 1595 | } |
| 1596 | } |
| 1597 | } |
| 1598 | |
| 1599 | /** |
| 1600 | * Makes sure the given array is a valid API response fragment |
| 1601 | * @param array &$arr |
| 1602 | */ |
| 1603 | protected function sanitizeArrayForAPI( &$arr ) { |
| 1604 | if ( !is_array( $arr ) ) { |
| 1605 | return; |
| 1606 | } |
| 1607 | |
| 1608 | $counter = 1; |
| 1609 | foreach ( $arr as $key => &$value ) { |
| 1610 | $sanitizedKey = $this->sanitizeKeyForAPI( $key ); |
| 1611 | if ( $sanitizedKey !== $key ) { |
| 1612 | if ( isset( $arr[$sanitizedKey] ) ) { |
| 1613 | // Make the sanitized keys hopefully unique. |
| 1614 | // To make it definitely unique would be too much effort, given that |
| 1615 | // sanitizing is only needed for misformatted metadata anyway, but |
| 1616 | // this at least covers the case when $arr is numeric. |
| 1617 | $sanitizedKey .= $counter; |
| 1618 | ++$counter; |
| 1619 | } |
| 1620 | $arr[$sanitizedKey] = $arr[$key]; |
| 1621 | unset( $arr[$key] ); |
| 1622 | } |
| 1623 | if ( is_array( $value ) ) { |
| 1624 | $this->sanitizeArrayForAPI( $value ); |
| 1625 | } |
| 1626 | } |
| 1627 | unset( $value ); |
| 1628 | |
| 1629 | // Handle API metadata keys (particularly "_type") |
| 1630 | $keys = array_filter( array_keys( $arr ), ApiResult::isMetadataKey( ... ) ); |
| 1631 | if ( $keys ) { |
| 1632 | ApiResult::setPreserveKeysList( $arr, $keys ); |
| 1633 | } |
| 1634 | } |
| 1635 | |
| 1636 | /** |
| 1637 | * Turns a string into a valid API identifier. |
| 1638 | * @param string $key |
| 1639 | * @return string |
| 1640 | * @since 1.23 |
| 1641 | */ |
| 1642 | protected function sanitizeKeyForAPI( $key ) { |
| 1643 | // drop all characters which are not valid in an XML tag name |
| 1644 | // a bunch of non-ASCII letters would be valid but probably won't |
| 1645 | // be used so we take the easy way |
| 1646 | $key = preg_replace( '/[^a-zA-Z0-9_:.\-]/', '', $key ); |
| 1647 | // drop characters which are invalid at the first position |
| 1648 | $key = preg_replace( '/^[\d\-.]+/', '', $key ); |
| 1649 | |
| 1650 | if ( $key === '' ) { |
| 1651 | $key = '_'; |
| 1652 | // special case for an internal keyword |
| 1653 | } elseif ( $key === '_element' ) { |
| 1654 | $key = 'element'; |
| 1655 | } |
| 1656 | |
| 1657 | return $key; |
| 1658 | } |
| 1659 | |
| 1660 | /** |
| 1661 | * Returns a list of languages (first is best) to use when formatting multilang fields, |
| 1662 | * based on user and site preferences. |
| 1663 | * @return array |
| 1664 | * @since 1.23 |
| 1665 | */ |
| 1666 | protected function getPriorityLanguages() { |
| 1667 | $priorityLanguages = MediaWikiServices::getInstance() |
| 1668 | ->getLanguageFallback() |
| 1669 | ->getAllIncludingSiteLanguage( $this->getLanguage()->getCode() ); |
| 1670 | $priorityLanguages = array_merge( |
| 1671 | (array)$this->getLanguage()->getCode(), |
| 1672 | $priorityLanguages[0], |
| 1673 | $priorityLanguages[1] |
| 1674 | ); |
| 1675 | |
| 1676 | return $priorityLanguages; |
| 1677 | } |
| 1678 | } |