Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
6.47% covered (danger)
6.47%
11 / 170
7.69% covered (danger)
7.69%
1 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
AbstractQTIItemExporter
6.47% covered (danger)
6.47%
11 / 170
7.69% covered (danger)
7.69%
1 / 13
1933.07
0.00% covered (danger)
0.00%
0 / 1
 buildBasePath
n/a
0 / 0
n/a
0 / 0
0
 renderManifest
n/a
0 / 0
n/a
0 / 0
0
 itemContentPostProcessing
n/a
0 / 0
n/a
0 / 0
0
 getQTIVersion
n/a
0 / 0
n/a
0 / 0
0
 export
0.00% covered (danger)
0.00%
0 / 99
0.00% covered (danger)
0.00%
0 / 1
600
 setCorrectQTIVersion
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 getExportErrorMessage
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 copyAssetFile
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
20
 getAssets
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
42
 getPortableElementAssets
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 getStorageDirectory
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 getServiceManager
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getMetadataExporter
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 addAssetStylesheetToZip
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 getUniqueAssetDirectoryByLink
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 buildAssetStylesheetPath
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 getAssetStylesheetLoader
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) 2008-2010 (original work) Deutsche Institut für Internationale Pädagogische Forschung
19 *                         (under the project TAO-TRANSFER);
20 *               2009-2012 (update and modification) Public Research Centre Henri Tudor
21 *                         (under the project TAO-SUSTAIN & TAO-DEV);
22 *               2013-2022 (update and modification) Open Assessment Technologies SA (under the project TAO-PRODUCT);
23 */
24
25namespace oat\taoQtiItem\model\Export;
26
27use oat\oatbox\filesystem\FilesystemException;
28use oat\oatbox\reporting\Report;
29use oat\tao\helpers\Base64;
30use oat\tao\model\media\MediaBrowser;
31use oat\taoQtiItem\model\Export\Exception\AssetStylesheetZipTransferException;
32use oat\taoQtiItem\model\Export\Stylesheet\AssetStylesheetLoader;
33use core_kernel_classes_Property;
34use DOMDocument;
35use oat\oatbox\filesystem\Directory;
36use oat\oatbox\service\ServiceManager;
37use oat\tao\model\media\ProcessedFileStreamAware;
38use oat\tao\model\media\sourceStrategy\HttpSource;
39use oat\taoItems\model\media\ItemMediaResolver;
40use oat\taoItems\model\media\LocalItemSource;
41use oat\taoQtiItem\model\portableElement\exception\PortableElementException;
42use oat\taoQtiItem\model\portableElement\exception\PortableElementInvalidAssetException;
43use oat\taoQtiItem\model\portableElement\PortableElementService;
44use oat\taoQtiItem\model\qti\AssetParser;
45use oat\taoQtiItem\model\qti\Element;
46use oat\taoQtiItem\model\qti\exception\ExportException;
47use oat\taoQtiItem\model\qti\metadata\exporter\MetadataExporter;
48use oat\taoQtiItem\model\qti\metadata\MetadataService;
49use oat\taoQtiItem\model\qti\Service;
50use Psr\Http\Message\StreamInterface;
51use tao_models_classes_FileNotFoundException;
52use taoItems_models_classes_ItemExporter;
53
54abstract class AbstractQTIItemExporter extends taoItems_models_classes_ItemExporter
55{
56    public const PROPERTY_LINK = 'http://www.tao.lu/Ontologies/TAOMedia.rdf#Link';
57    public const ASSETS_DIRECTORY_NAME = 'assets';
58    public const CSS_DIRECTORY_NAME = 'css';
59    public const CSS_FILE_NAME = 'tao-user-styles.css';
60
61    /**
62     * List of regexp of media that should be excluded from retrieval
63     */
64    private static $BLACKLIST = [
65        '/^data:[^\/]+\/[^;]+(;charset=[\w]+)?;base64,/'
66    ];
67
68    /**
69     * @var MetadataExporter Service to export metadata to IMSManifest
70     */
71    protected $metadataExporter;
72
73    abstract public function buildBasePath();
74
75    abstract protected function renderManifest(array $options, array $qtiItemData);
76
77    abstract protected function itemContentPostProcessing($content);
78
79    abstract protected function getQTIVersion(): string;
80
81    /**
82     * Overriden export from QTI items.
83     *
84     * @see taoItems_models_classes_ItemExporter::export()
85     * @param array $options An array of options.
86     * @return \common_report_Report
87     * @throws ExportException
88     * @throws \common_Exception
89     * @throws \common_exception_Error
90     * @throws \core_kernel_persistence_Exception
91     * @throws PortableElementInvalidAssetException
92     */
93    public function export($options = [])
94    {
95        $report = \common_report_Report::createSuccess();
96        $asApip = isset($options['apip']) && $options['apip'] === true;
97
98        $lang = \common_session_SessionManager::getSession()->getDataLanguage();
99        $basePath = $this->buildBasePath();
100
101        if (is_null($this->getItemModel())) {
102            $report->setMessage($this->getExportErrorMessage(__('not a QTI item')));
103            $report->setType(\common_report_Report::TYPE_ERROR);
104            return $report;
105        }
106        $dataFile = (string)$this->getItemModel()->getOnePropertyValue(
107            new core_kernel_classes_Property(\taoItems_models_classes_ItemsService::TAO_ITEM_MODEL_DATAFILE_PROPERTY)
108        );
109        $resolver = new ItemMediaResolver($this->getItem(), $lang);
110        $replacementList = [];
111        $portableElements = $this->getPortableElementAssets($this->getItem(), $lang);
112        $service = new PortableElementService();
113        $service->setServiceLocator(ServiceManager::getServiceManager());
114
115        $portableElementsToExport = [];
116        $portableAssets = [];
117
118        foreach ($portableElements as $element) {
119            if (!$element instanceof Element) {
120                continue;
121            }
122
123            try {
124                $object = $service->getPortableObjectFromInstance($element);
125            } catch (PortableElementException $e) {
126                $message = __('Fail to export item') . ' (' . $this->getItem()->getLabel() . '): ' . $e->getMessage();
127                return \common_report_Report::createFailure($message);
128            }
129
130            $portableElementExporter = $object->getModel()->getExporter($object, $this);
131            $portableElementsToExport[$element->getTypeIdentifier()] = $portableElementExporter;
132            try {
133                $portableAssets = array_merge(
134                    $portableAssets,
135                    $portableElementExporter->copyAssetFiles($replacementList)
136                );
137            } catch (\tao_models_classes_FileNotFoundException $e) {
138                \common_Logger::i($e->getMessage());
139                $report->setMessage(
140                    'Missing portable element asset for "' . $object->getTypeIdentifier() . '"": ' . $e->getMessage()
141                );
142                $report->setType(\common_report_Report::TYPE_ERROR);
143            }
144        }
145
146        $assets = $this->getAssets($this->getItem(), $lang);
147        foreach ($assets as $assetUrl) {
148            try {
149                $mediaAsset = $resolver->resolve($assetUrl);
150                $mediaSource = $mediaAsset->getMediaSource();
151                $mediaIdentifier = $mediaAsset->getMediaIdentifier();
152
153                if (!$mediaSource instanceof HttpSource && !Base64::isEncodedImage($mediaIdentifier)) {
154                    $link = $mediaIdentifier;
155
156                    if ($mediaSource instanceof ProcessedFileStreamAware) {
157                        $stream = $mediaSource->getProcessedFileStream($link);
158                    } else {
159                        $stream = $mediaSource->getFileStream($link);
160                    }
161
162                    $baseName = ($mediaSource instanceof LocalItemSource)
163                        ? $link
164                        : $this->getUniqueAssetDirectoryByLink($mediaSource, $link);
165
166                    $replacement = $this->copyAssetFile($stream, $basePath, $baseName, $replacementList);
167                    $this->addAssetStylesheetToZip($link, dirname($baseName), $basePath);
168                    $replacementList[$assetUrl] = $replacement;
169                }
170            } catch (tao_models_classes_FileNotFoundException $e) {
171                $replacementList[$assetUrl] = '';
172                $report->setMessage('Missing resource for ' . $assetUrl);
173                $report->setType(Report::TYPE_ERROR);
174            } catch (AssetStylesheetZipTransferException $e) {
175                $replacementList[$assetUrl] = '';
176                $report->setMessage($e->getMessage());
177                $report->setType(Report::TYPE_ERROR);
178            } catch (FilesystemException $exception) {
179                $replacementList[$assetUrl] = '';
180                $report->setMessage($exception->getMessage());
181                $report->setType(Report::TYPE_ERROR);
182                return $report;
183            }
184        }
185
186        try {
187            $xml = Service::singleton()->getXmlByRdfItem($this->getItem());
188        } catch (FilesystemException $e) {
189            $report->setMessage($this->getExportErrorMessage(__('cannot find QTI XML')));
190            $report->setType(\common_report_Report::TYPE_ERROR);
191            return $report;
192        }
193
194        $dom = new DOMDocument('1.0', 'UTF-8');
195        $dom->preserveWhiteSpace = false;
196        $dom->formatOutput = true;
197
198        if ($dom->loadXML($xml) === true) {
199            $xpath = new \DOMXPath($dom);
200            $attributeNodes = $xpath->query('//@*');
201            $portableEntryNodes = $xpath->query("//*[local-name()='entry']|//*[local-name()='property']") ?: [];
202            unset($xpath);
203
204            foreach ($attributeNodes as $node) {
205                if (isset($replacementList[$node->value])) {
206                    $node->value = htmlspecialchars($replacementList[$node->value], ENT_QUOTES | ENT_XML1);
207                }
208            }
209            foreach ($portableEntryNodes as $node) {
210                $node->nodeValue = strtr(htmlentities($node->nodeValue, ENT_XML1), $replacementList);
211            }
212            foreach ($portableElementsToExport as $portableElementExporter) {
213                $portableElementExporter->exportDom($dom);
214            }
215        } else {
216            $report->setMessage($this->getExportErrorMessage(__('cannot load QTI XML')));
217            $report->setType(\common_report_Report::TYPE_ERROR);
218            return $report;
219        }
220
221        if (($content = $dom->saveXML()) === false) {
222            $report->setMessage($this->getExportErrorMessage(__('invalid QTI XML')));
223            $report->setType(\common_report_Report::TYPE_ERROR);
224        }
225
226        $content = $this->setCorrectQTIVersion((string) $content);
227
228        // Possibility to delegate (if necessary) some item content post-processing to sub-classes.
229        $content = $this->itemContentPostProcessing($content);
230
231        // add xml file
232        $this->getZip()->addFromString($basePath . '/' . $dataFile, $content);
233
234        if (!$report->getMessage()) {
235            $report->setMessage(__('Item "%s" is ready to be exported', $this->getItem()->getLabel()));
236        }
237
238        ///return some useful data to the export report
239        $report->setData(['portableAssets' => $portableAssets]);
240
241        return $report;
242    }
243
244    protected function setCorrectQTIVersion(string $itemQTI): string
245    {
246        $processed = preg_replace(
247            '/(http:\/\/www\.imsglobal\.org\/xsd\/qti\/)qtiv(\wp\w)/',
248            '$1qtiv' . $this->getQTIVersion(),
249            $itemQTI
250        );
251        $processed = preg_replace(
252            '/(http:\/\/www\.imsglobal\.org\/(xsd|question).+?)qti_v(\wp\w)/',
253            '$1qti_v' . $this->getQTIVersion(),
254            $processed
255        );
256
257        return $processed;
258    }
259
260    /**
261     * Format a consistent error reporting message
262     *
263     * @param string $errorMessage
264     * @return string
265     */
266    private function getExportErrorMessage($errorMessage)
267    {
268        return __('Item "%s" cannot be exported: %s', $this->getItem()->getLabel(), $errorMessage);
269    }
270
271    public function copyAssetFile(StreamInterface $stream, $basePath, $baseName, &$replacementList)
272    {
273        $replacement = $baseName;
274
275        $count = 0;
276        while (in_array($replacement, $replacementList)) {
277            $dot = strrpos($baseName, '.');
278            $replacement = $dot !== false
279                ? substr($baseName, 0, $dot) . '_' . $count . substr($baseName, $dot)
280                : $baseName . $count;
281            $count++;
282        }
283
284        // To check if replacement is to replace basename ???
285        // Please check it seriously next time!
286        $newRelPath = (empty($basePath) ? '' : $basePath . '/') . preg_replace('/^(.\/)/', '', $replacement);
287        $this->addFile($stream, $newRelPath);
288        $stream->close();
289        return $replacement;
290    }
291
292    /**
293     * Get the item's assets
294     *
295     * @param \core_kernel_classes_Resource $item The item
296     * @param string $lang The item lang
297     *
298     * @return string[] The assets URLs
299     */
300    protected function getAssets(\core_kernel_classes_Resource $item, $lang)
301    {
302        $qtiItem = Service::singleton()->getDataItemByRdfItem($item, $lang);
303        if (is_null($qtiItem)) {
304            return [];
305        }
306        $assetParser = new AssetParser($qtiItem, $this->getStorageDirectory($item, $lang));
307        $assetParser->setGetSharedLibraries(false);
308        $returnValue = [];
309        foreach ($assetParser->extract() as $type => $assets) {
310            foreach ($assets as $assetUrl) {
311                foreach (self::$BLACKLIST as $blacklist) {
312                    if (preg_match($blacklist, $assetUrl) === 1) {
313                        continue(2);
314                    }
315                }
316                $returnValue[] = $assetUrl;
317            }
318        }
319        return $returnValue;
320    }
321
322    protected function getPortableElementAssets(\core_kernel_classes_Resource $item, $lang)
323    {
324        $qtiItem = Service::singleton()->getDataItemByRdfItem($item, $lang);
325        if (is_null($qtiItem)) {
326            return [];
327        }
328        $directory = $this->getStorageDirectory($item, $lang);
329        $assetParser = new AssetParser($qtiItem, $directory);
330        $assetParser->setGetCustomElementDefinition(true);
331        return $assetParser->extractPortableAssetElements();
332    }
333
334    /**
335     * Get the item's directory
336     *
337     * @param \core_kernel_classes_Resource $item The item
338     * @param string $lang The item lang
339     *
340     * @return Directory The directory
341     */
342    protected function getStorageDirectory(\core_kernel_classes_Resource $item, $lang)
343    {
344        $itemService = \taoItems_models_classes_ItemsService::singleton();
345        $directory = $itemService->getItemDirectory($item, $lang);
346
347        //we should use be language unaware for storage manipulation
348        $path = str_replace($lang, '', $directory->getPrefix());
349        return $itemService->getDefaultItemDirectory()->getDirectory($path);
350    }
351
352    protected function getServiceManager()
353    {
354        return ServiceManager::getServiceManager();
355    }
356
357    /**
358     * Get the service to export Metadata
359     *
360     * @return MetadataExporter
361     */
362    protected function getMetadataExporter()
363    {
364        if (!$this->metadataExporter) {
365            $this->metadataExporter = $this->getServiceManager()->get(MetadataService::SERVICE_ID)->getExporter();
366        }
367        return $this->metadataExporter;
368    }
369
370    /**
371     * @throws AssetStylesheetZipTransferException
372     */
373    private function addAssetStylesheetToZip(string $link, string $baseDirectoryName, string $basepath): void
374    {
375        if ($assetStylesheets = $this->getAssetStylesheetLoader()->loadAssetsFromAssetResource($link)) {
376            foreach ($assetStylesheets as $stylesheetFile) {
377                $this->addFile(
378                    $stylesheetFile['stream'],
379                    $this->buildAssetStylesheetPath($basepath, $baseDirectoryName, basename($stylesheetFile['path']))
380                );
381            }
382        }
383    }
384
385    private function getUniqueAssetDirectoryByLink(MediaBrowser $mediaSource, string $link): string
386    {
387        return self::ASSETS_DIRECTORY_NAME
388            . DIRECTORY_SEPARATOR
389            . $mediaSource->getFileInfo($link)['link'];
390    }
391
392    private function buildAssetStylesheetPath(string $basepath, string $baseDirectoryName, string $fileName): string
393    {
394        return implode(
395            DIRECTORY_SEPARATOR,
396            [
397                $basepath,
398                $baseDirectoryName,
399                self::CSS_DIRECTORY_NAME,
400                $fileName
401            ]
402        );
403    }
404
405    private function getAssetStylesheetLoader(): AssetStylesheetLoader
406    {
407        return $this->getServiceManager()->get(AssetStylesheetLoader::class);
408    }
409}