Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
0.00% |
0 / 176 |
|
0.00% |
0 / 13 |
CRAP | |
0.00% |
0 / 1 |
| QtiExtractor | |
0.00% |
0 / 176 |
|
0.00% |
0 / 13 |
2756 | |
0.00% |
0 / 1 |
| setItem | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
| loadXml | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
12 | |||
| addColumn | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
| run | |
0.00% |
0 / 17 |
|
0.00% |
0 / 1 |
56 | |||
| getData | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| extractInteractions | |
0.00% |
0 / 93 |
|
0.00% |
0 / 1 |
380 | |||
| sanitizeNodeToValue | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
2 | |||
| getRightAnswer | |
0.00% |
0 / 20 |
|
0.00% |
0 / 1 |
56 | |||
| getNumberOfChoices | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
| getChoices | |
0.00% |
0 / 14 |
|
0.00% |
0 / 1 |
42 | |||
| getInteractionType | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
| getResponseIdentifier | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| __toPhpCode | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| 1 | <?php |
| 2 | |
| 3 | /** |
| 4 | * This program is free software; you can redistribute it and/or |
| 5 | * modify it under the terms of the GNU General Public License |
| 6 | * as published by the Free Software Foundation; under version 2 |
| 7 | * of the License (non-upgradable). |
| 8 | * |
| 9 | * This program is distributed in the hope that it will be useful, |
| 10 | * but WITHOUT ANY WARRANTY; without even the implied warranty of |
| 11 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
| 12 | * GNU General Public License for more details. |
| 13 | * |
| 14 | * You should have received a copy of the GNU General Public License |
| 15 | * along with this program; if not, write to the Free Software |
| 16 | * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
| 17 | * |
| 18 | * Copyright (c) 2015 (original work) Open Assessment Technologies SA; |
| 19 | * |
| 20 | * |
| 21 | */ |
| 22 | |
| 23 | namespace oat\taoQtiItem\model\flyExporter\extractor; |
| 24 | |
| 25 | use oat\oatbox\filesystem\FilesystemException; |
| 26 | use oat\taoQtiItem\model\qti\Service; |
| 27 | |
| 28 | /** |
| 29 | * Extract all given columns of item qti data |
| 30 | * |
| 31 | * Class QtiExtractor |
| 32 | * @package oat\taoDepp\model\export |
| 33 | */ |
| 34 | class QtiExtractor implements Extractor |
| 35 | { |
| 36 | /** |
| 37 | * Item to export |
| 38 | * @var \core_kernel_classes_Resource |
| 39 | */ |
| 40 | protected $item; |
| 41 | |
| 42 | /** |
| 43 | * Requested output columns |
| 44 | * @var array |
| 45 | */ |
| 46 | protected $columns = []; |
| 47 | |
| 48 | /** |
| 49 | * All data formatted as $columns |
| 50 | * @var array |
| 51 | */ |
| 52 | protected $data = []; |
| 53 | |
| 54 | /** |
| 55 | * Xml Dom of item content |
| 56 | * @var \DOMDocument |
| 57 | */ |
| 58 | protected $dom; |
| 59 | |
| 60 | /** |
| 61 | * Xpath of item Dom element |
| 62 | * @var \DOMXpath |
| 63 | */ |
| 64 | protected $xpath; |
| 65 | |
| 66 | /** |
| 67 | * All real interactions |
| 68 | * @var array |
| 69 | */ |
| 70 | protected $interactions = []; |
| 71 | |
| 72 | /** |
| 73 | * Work around to handle dynamic column length |
| 74 | * @var int |
| 75 | */ |
| 76 | protected $headerChoice = 0; |
| 77 | |
| 78 | /** |
| 79 | * Set item to extract |
| 80 | * |
| 81 | * @param \core_kernel_classes_Resource $item |
| 82 | * @return $this |
| 83 | * @throws ExtractorException |
| 84 | */ |
| 85 | public function setItem(\core_kernel_classes_Resource $item) |
| 86 | { |
| 87 | $this->item = $item; |
| 88 | $this->loadXml($item); |
| 89 | |
| 90 | return $this; |
| 91 | } |
| 92 | |
| 93 | /** |
| 94 | * Load Dom & Xpath of xml item content & register xpath namespace |
| 95 | * |
| 96 | * @param \core_kernel_classes_Resource $item |
| 97 | * @return $this |
| 98 | * @throws ExtractorException |
| 99 | */ |
| 100 | private function loadXml(\core_kernel_classes_Resource $item) |
| 101 | { |
| 102 | $itemService = Service::singleton(); |
| 103 | |
| 104 | try { |
| 105 | $xml = $itemService->getXmlByRdfItem($item); |
| 106 | if (empty($xml)) { |
| 107 | throw new ExtractorException('No content found for item ' . $item->getUri()); |
| 108 | } |
| 109 | } catch (FilesystemException $e) { |
| 110 | throw new ExtractorException( |
| 111 | 'qti.xml file was not found for item ' . $item->getUri() . '; The item might be empty.' |
| 112 | ); |
| 113 | } |
| 114 | |
| 115 | $this->dom = new \DOMDocument(); |
| 116 | $this->dom->loadXml($xml); |
| 117 | $this->xpath = new \DOMXpath($this->dom); |
| 118 | $this->xpath->registerNamespace('qti', $this->dom->documentElement->namespaceURI); |
| 119 | |
| 120 | return $this; |
| 121 | } |
| 122 | |
| 123 | /** |
| 124 | * Add column to export with associate config |
| 125 | * |
| 126 | * @param $column |
| 127 | * @param array $config |
| 128 | * @return $this |
| 129 | */ |
| 130 | public function addColumn($column, array $config) |
| 131 | { |
| 132 | $this->columns[$column] = $config; |
| 133 | |
| 134 | return $this; |
| 135 | } |
| 136 | |
| 137 | /** |
| 138 | * Launch interactions extraction |
| 139 | * Transform interactions array to output data |
| 140 | * Use callback & valuesAsColumns |
| 141 | * |
| 142 | * @return $this |
| 143 | */ |
| 144 | public function run() |
| 145 | { |
| 146 | $this->extractInteractions(); |
| 147 | |
| 148 | $this->data = $line = []; |
| 149 | |
| 150 | foreach ($this->interactions as $interaction) { |
| 151 | foreach ($this->columns as $column => $config) { |
| 152 | if ( |
| 153 | isset($config['callback']) |
| 154 | && method_exists($this, $config['callback']) |
| 155 | ) { |
| 156 | $params = []; |
| 157 | if (isset($config['callbackParameters'])) { |
| 158 | $params = $config['callbackParameters']; |
| 159 | } |
| 160 | $functionCall = $config['callback']; |
| 161 | $callbackValue = call_user_func([$this, $functionCall], $interaction, $params); |
| 162 | if (isset($config['valuesAsColumns'])) { |
| 163 | $line[$interaction['id']] = array_merge($line[$interaction['id']], $callbackValue); |
| 164 | } else { |
| 165 | $line[$interaction['id']][$column] = $callbackValue; |
| 166 | } |
| 167 | } |
| 168 | } |
| 169 | } |
| 170 | $this->data = $line; |
| 171 | $this->columns = $this->interactions = []; |
| 172 | return $this; |
| 173 | } |
| 174 | |
| 175 | /** |
| 176 | * Return output data |
| 177 | * |
| 178 | * @return array |
| 179 | */ |
| 180 | public function getData() |
| 181 | { |
| 182 | return $this->data; |
| 183 | } |
| 184 | |
| 185 | /** |
| 186 | * Extract all interaction by find interaction node & relative choices |
| 187 | * Find right answer & resolve identifier to choice name |
| 188 | * Output example of item interactions: |
| 189 | * array ( |
| 190 | * [...], |
| 191 | * array( |
| 192 | * "id" => "56e7d1397ad57", |
| 193 | * "type" => "Match", |
| 194 | * "choices" => array ( |
| 195 | * "M" => "Mouse", |
| 196 | * "S" => "Soda", |
| 197 | * "W" => "Wheel", |
| 198 | * "D" => "DarthVader", |
| 199 | * "A" => "Astronaut", |
| 200 | * "C" => "Computer", |
| 201 | * "P" => "Plane", |
| 202 | * "N" => "Number", |
| 203 | * ), |
| 204 | * "responses" => array ( |
| 205 | * 0 => "M C" |
| 206 | * ), |
| 207 | * "responseIdentifier" => "RESPONSE" |
| 208 | * ) |
| 209 | * ) |
| 210 | * |
| 211 | * @return $this |
| 212 | */ |
| 213 | protected function extractInteractions() |
| 214 | { |
| 215 | $elements = [ |
| 216 | // Multiple choice |
| 217 | 'Choice' => ['domInteraction' => 'choiceInteraction', 'xpathChoice' => './/qti:simpleChoice'], |
| 218 | 'Order' => ['domInteraction' => 'orderInteraction', 'xpathChoice' => './/qti:simpleChoice'], |
| 219 | 'Match' => [ |
| 220 | 'domInteraction' => 'matchInteraction', |
| 221 | 'xpathChoice' => './/qti:simpleAssociableChoice', |
| 222 | ], |
| 223 | 'Associate' => [ |
| 224 | 'domInteraction' => 'associateInteraction', |
| 225 | 'xpathChoice' => './/qti:simpleAssociableChoice', |
| 226 | ], |
| 227 | 'Gap Match' => ['domInteraction' => 'gapMatchInteraction', 'xpathChoice' => './/qti:gapText'], |
| 228 | 'Hot text' => ['domInteraction' => 'hottextInteraction', 'xpathChoice' => './/qti:hottext'], |
| 229 | 'Inline choice' => [ |
| 230 | 'domInteraction' => 'inlineChoiceInteraction', |
| 231 | 'xpathChoice' => './/qti:inlineChoice', |
| 232 | ], |
| 233 | 'Graphic hotspot' => ['domInteraction' => 'hotspotInteraction', 'xpathChoice' => './/qti:hotspotChoice'], |
| 234 | 'Graphic order' => [ |
| 235 | 'domInteraction' => 'graphicOrderInteraction', |
| 236 | 'xpathChoice' => './/qti:hotspotChoice', |
| 237 | ], |
| 238 | 'Graphic associate' => [ |
| 239 | 'domInteraction' => 'graphicAssociateInteraction', |
| 240 | 'xpathChoice' => './/qti:associableHotspot', |
| 241 | ], |
| 242 | 'Graphic gap match' => ['domInteraction' => 'graphicGapMatchInteraction', 'xpathChoice' => './/qti:gapImg'], |
| 243 | |
| 244 | //Scaffholding |
| 245 | 'ScaffHolding' => [ |
| 246 | 'xpathInteraction' => '//*[@customInteractionTypeIdentifier="adaptiveChoiceInteraction"]', |
| 247 | 'xpathChoice' => 'descendant::*[@class="qti-choice"]' |
| 248 | ], |
| 249 | |
| 250 | // Custom PCI interactions; Proper interaction type name will be determined by an xpath query |
| 251 | 'Custom Interaction' => [ |
| 252 | 'domInteraction' => 'customInteraction' |
| 253 | ], |
| 254 | |
| 255 | // Simple interaction |
| 256 | 'Extended text' => ['domInteraction' => 'extendedTextInteraction'], |
| 257 | 'Slider' => ['domInteraction' => 'sliderInteraction'], |
| 258 | 'Upload file' => ['domInteraction' => 'uploadInteraction'], |
| 259 | 'Text entry' => ['domInteraction' => 'textEntryInteraction'], |
| 260 | 'End attempt' => ['domInteraction' => 'endAttemptInteraction'], |
| 261 | ]; |
| 262 | |
| 263 | /** |
| 264 | * foreach all interactions type |
| 265 | */ |
| 266 | foreach ($elements as $element => $parser) { |
| 267 | if (isset($parser['domInteraction'])) { |
| 268 | $interactionNode = $this->dom->getElementsByTagName($parser['domInteraction']); |
| 269 | } elseif (isset($parser['xpathInteraction'])) { |
| 270 | $interactionNode = $this->xpath->query($parser['xpathInteraction']); |
| 271 | } else { |
| 272 | continue; |
| 273 | } |
| 274 | |
| 275 | if ($interactionNode->length == 0) { |
| 276 | continue; |
| 277 | } |
| 278 | |
| 279 | /** |
| 280 | * foreach all real interactions |
| 281 | */ |
| 282 | for ($i = 0; $i < $interactionNode->length; $i++) { |
| 283 | $interaction = []; |
| 284 | $interaction['id'] = uniqid(); |
| 285 | $interaction['type'] = $element; |
| 286 | $interaction['choices'] = []; |
| 287 | $interaction['responses'] = []; |
| 288 | |
| 289 | if ($parser['domInteraction'] === 'customInteraction') { |
| 290 | // figure out the proper type name of a custom interaction |
| 291 | $portableCustomNode = $this->xpath->query( |
| 292 | './pci:portableCustomInteraction', |
| 293 | $interactionNode->item($i) |
| 294 | ); |
| 295 | |
| 296 | if ($portableCustomNode->length) { |
| 297 | $interaction['type'] = ucfirst( |
| 298 | str_replace( |
| 299 | 'Interaction', |
| 300 | '', |
| 301 | $portableCustomNode->item(0)->getAttribute('customInteractionTypeIdentifier') |
| 302 | ) |
| 303 | ); |
| 304 | } |
| 305 | } |
| 306 | |
| 307 | /** |
| 308 | * Interaction right answers |
| 309 | */ |
| 310 | $interaction['responseIdentifier'] = $interactionNode->item($i)->getAttribute('responseIdentifier'); |
| 311 | $rightAnswer = $this->xpath->query( |
| 312 | './qti:responseDeclaration[@identifier="' . $interaction['responseIdentifier'] . '"]' |
| 313 | ); |
| 314 | |
| 315 | if ($rightAnswer->length > 0) { |
| 316 | $answers = $rightAnswer->item(0)->textContent; |
| 317 | if (!empty($answers)) { |
| 318 | foreach (explode(PHP_EOL, $answers) as $answer) { |
| 319 | if (trim($answer) !== '') { |
| 320 | $interaction['responses'][] = $answer; |
| 321 | } |
| 322 | } |
| 323 | } |
| 324 | } |
| 325 | |
| 326 | /** |
| 327 | * Interaction choices |
| 328 | */ |
| 329 | $choiceNode = ''; |
| 330 | if (!empty($parser['domChoice'])) { |
| 331 | $choiceNode = $this->dom->getElementsByTagName($parser['domChoice']); |
| 332 | } elseif (!empty($parser['xpathChoice'])) { |
| 333 | $choiceNode = $this->xpath->query($parser['xpathChoice'], $interactionNode->item($i)); |
| 334 | } |
| 335 | |
| 336 | if (!empty($choiceNode) && $choiceNode->length > 0) { |
| 337 | for ($j = 0; $j < $choiceNode->length; $j++) { |
| 338 | $identifier = $choiceNode->item($j)->getAttribute('identifier'); |
| 339 | $value = $this->sanitizeNodeToValue($this->dom->saveHtml($choiceNode->item($j))); |
| 340 | |
| 341 | //Image |
| 342 | if ($value === '') { |
| 343 | $imgNode = $this->xpath->query('./qti:img/@src', $choiceNode->item($j)); |
| 344 | if ($imgNode->length > 0) { |
| 345 | $value = 'image' . $j . '_' . $imgNode->item(0)->value; |
| 346 | } |
| 347 | } |
| 348 | $interaction['choices'][$identifier] = $value; |
| 349 | } |
| 350 | } |
| 351 | |
| 352 | $this->interactions[] = $interaction; |
| 353 | } |
| 354 | } |
| 355 | return $this; |
| 356 | } |
| 357 | |
| 358 | /** |
| 359 | * Remove first and last xml tag from string |
| 360 | * Transform variable to string value |
| 361 | * |
| 362 | * @param $value |
| 363 | * @return string |
| 364 | */ |
| 365 | protected function sanitizeNodeToValue($value) |
| 366 | { |
| 367 | $first = strpos($value, '>') + 1; |
| 368 | $last = strrpos($value, '<') - $first; |
| 369 | $value = substr($value, $first, $last); |
| 370 | $value = str_replace('"', "\"\"", $value); |
| 371 | return trim($value); |
| 372 | } |
| 373 | |
| 374 | /** |
| 375 | * Callback to retrieve right answers |
| 376 | * Find $responses & resolve identifier with $choices |
| 377 | * |
| 378 | * @param $interaction |
| 379 | * @return string |
| 380 | */ |
| 381 | public function getRightAnswer($interaction, $params) |
| 382 | { |
| 383 | $return = ['BR_identifier' => [], 'BR_label' => []]; |
| 384 | if (isset($interaction['responses'])) { |
| 385 | foreach ($interaction['responses'] as $response) { |
| 386 | $allResponses = explode(' ', trim($response)); |
| 387 | $returnLabel = []; |
| 388 | $returnIdentifier = []; |
| 389 | |
| 390 | foreach ($allResponses as $partialResponse) { |
| 391 | if ( |
| 392 | isset($interaction['choices'][$partialResponse]) |
| 393 | && $interaction['choices'][$partialResponse] !== '' |
| 394 | ) { |
| 395 | $returnLabel[] = $interaction['choices'][$partialResponse]; |
| 396 | } else { |
| 397 | $returnLabel[] = ''; |
| 398 | } |
| 399 | $returnIdentifier[] = $partialResponse; |
| 400 | } |
| 401 | |
| 402 | $return['BR_identifier'][] = implode(' ', $returnIdentifier); |
| 403 | $return['BR_label'][] = implode(' ', $returnLabel); |
| 404 | } |
| 405 | } |
| 406 | if (isset($params['delimiter'])) { |
| 407 | $delimiter = $params['delimiter']; |
| 408 | } else { |
| 409 | $delimiter = self::DEFAULT_PROPERTY_DELIMITER; |
| 410 | } |
| 411 | |
| 412 | $return['BR_identifier'] = implode($delimiter, $return['BR_identifier']); |
| 413 | $return['BR_label'] = implode($delimiter, $return['BR_label']); |
| 414 | |
| 415 | return $return; |
| 416 | } |
| 417 | |
| 418 | /** |
| 419 | * Callback to retrieve number of choices |
| 420 | * |
| 421 | * @param $interaction |
| 422 | * @return int|string |
| 423 | */ |
| 424 | public function getNumberOfChoices($interaction) |
| 425 | { |
| 426 | if (!empty($interaction['choices'])) { |
| 427 | return count($interaction['choices']); |
| 428 | } else { |
| 429 | return ''; |
| 430 | } |
| 431 | } |
| 432 | |
| 433 | /** |
| 434 | * Callback to retrieve all choices |
| 435 | * Add dynamic column to have same columns number as other |
| 436 | * |
| 437 | * @param $interaction |
| 438 | * @return array |
| 439 | */ |
| 440 | public function getChoices($interaction) |
| 441 | { |
| 442 | $return = []; |
| 443 | if (isset($interaction['choices'])) { |
| 444 | $i = 1; |
| 445 | foreach ($interaction['choices'] as $identifier => $choice) { |
| 446 | $return['choice_identifier_' . $i] = $identifier; |
| 447 | $return['choice_label_' . $i] = ($choice) ?: ''; |
| 448 | $i++; |
| 449 | } |
| 450 | if ($this->headerChoice > count($return)) { |
| 451 | while ($this->headerChoice > count($return)) { |
| 452 | $return['choice_identifier_' . $i] = ''; |
| 453 | $return['choice_label_' . $i] = ''; |
| 454 | $i++; |
| 455 | } |
| 456 | } else { |
| 457 | $this->headerChoice = count($return); |
| 458 | } |
| 459 | } |
| 460 | return $return; |
| 461 | } |
| 462 | |
| 463 | /** |
| 464 | * Callback to retrieve interaction type |
| 465 | * |
| 466 | * @param $interaction |
| 467 | * @return mixed |
| 468 | * @throws ExtractorException |
| 469 | */ |
| 470 | public function getInteractionType($interaction) |
| 471 | { |
| 472 | if (isset($interaction['type'])) { |
| 473 | return $interaction['type']; |
| 474 | } else { |
| 475 | throw new ExtractorException('Interaction malformed: missing type.'); |
| 476 | } |
| 477 | } |
| 478 | |
| 479 | /** |
| 480 | * Callback to retrieve interaction response identifier |
| 481 | * |
| 482 | * @param $interaction |
| 483 | * @return mixed |
| 484 | */ |
| 485 | public function getResponseIdentifier($interaction) |
| 486 | { |
| 487 | return $interaction['responseIdentifier']; |
| 488 | } |
| 489 | |
| 490 | /** |
| 491 | * Get human readable declaration class |
| 492 | * @return string |
| 493 | */ |
| 494 | public function __toPhpCode() |
| 495 | { |
| 496 | return 'new ' . get_class($this) . '()'; |
| 497 | } |
| 498 | } |