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