Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 176
0.00% covered (danger)
0.00%
0 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
QtiExtractor
0.00% covered (danger)
0.00%
0 / 176
0.00% covered (danger)
0.00%
0 / 13
2756
0.00% covered (danger)
0.00%
0 / 1
 setItem
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 loadXml
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
12
 addColumn
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 run
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
56
 getData
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 extractInteractions
0.00% covered (danger)
0.00%
0 / 93
0.00% covered (danger)
0.00%
0 / 1
380
 sanitizeNodeToValue
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 getRightAnswer
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
56
 getNumberOfChoices
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getChoices
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
42
 getInteractionType
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getResponseIdentifier
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 __toPhpCode
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
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
23namespace oat\taoQtiItem\model\flyExporter\extractor;
24
25use oat\oatbox\filesystem\FilesystemException;
26use 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 */
34class 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}