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