Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
6.47% |
11 / 170 |
|
7.69% |
1 / 13 |
CRAP | |
0.00% |
0 / 1 |
AbstractQTIItemExporter | |
6.47% |
11 / 170 |
|
7.69% |
1 / 13 |
1933.07 | |
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% |
0 / 99 |
|
0.00% |
0 / 1 |
600 | |||
setCorrectQTIVersion | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
1 | |||
getExportErrorMessage | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
copyAssetFile | |
0.00% |
0 / 12 |
|
0.00% |
0 / 1 |
20 | |||
getAssets | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
42 | |||
getPortableElementAssets | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
6 | |||
getStorageDirectory | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
2 | |||
getServiceManager | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getMetadataExporter | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
addAssetStylesheetToZip | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
12 | |||
getUniqueAssetDirectoryByLink | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
buildAssetStylesheetPath | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
2 | |||
getAssetStylesheetLoader | |
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) 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 | |
25 | namespace oat\taoQtiItem\model\Export; |
26 | |
27 | use DOMAttr; |
28 | use oat\oatbox\filesystem\FilesystemException; |
29 | use oat\oatbox\reporting\Report; |
30 | use oat\tao\helpers\Base64; |
31 | use oat\tao\model\media\MediaBrowser; |
32 | use oat\taoQtiItem\model\Export\Exception\AssetStylesheetZipTransferException; |
33 | use oat\taoQtiItem\model\Export\Stylesheet\AssetStylesheetLoader; |
34 | use core_kernel_classes_Property; |
35 | use DOMDocument; |
36 | use oat\oatbox\filesystem\Directory; |
37 | use oat\oatbox\service\ServiceManager; |
38 | use oat\tao\model\media\ProcessedFileStreamAware; |
39 | use oat\tao\model\media\sourceStrategy\HttpSource; |
40 | use oat\taoItems\model\media\ItemMediaResolver; |
41 | use oat\taoItems\model\media\LocalItemSource; |
42 | use oat\taoQtiItem\model\portableElement\exception\PortableElementException; |
43 | use oat\taoQtiItem\model\portableElement\exception\PortableElementInvalidAssetException; |
44 | use oat\taoQtiItem\model\portableElement\PortableElementService; |
45 | use oat\taoQtiItem\model\qti\AssetParser; |
46 | use oat\taoQtiItem\model\qti\Element; |
47 | use oat\taoQtiItem\model\qti\exception\ExportException; |
48 | use oat\taoQtiItem\model\qti\metadata\exporter\MetadataExporter; |
49 | use oat\taoQtiItem\model\qti\metadata\MetadataService; |
50 | use oat\taoQtiItem\model\qti\Service; |
51 | use Psr\Http\Message\StreamInterface; |
52 | use tao_models_classes_FileNotFoundException; |
53 | use taoItems_models_classes_ItemExporter; |
54 | |
55 | abstract 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 | } |