Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
6.43% |
11 / 171 |
|
7.14% |
1 / 14 |
CRAP | |
0.00% |
0 / 1 |
AbstractQTIItemExporter | |
6.43% |
11 / 171 |
|
7.14% |
1 / 14 |
2015.82 | |
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 | |||
getTransformationService | |
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\Qti3Package\TransformationService; |
34 | use oat\taoQtiItem\model\Export\Stylesheet\AssetStylesheetLoader; |
35 | use core_kernel_classes_Property; |
36 | use DOMDocument; |
37 | use oat\oatbox\filesystem\Directory; |
38 | use oat\oatbox\service\ServiceManager; |
39 | use oat\tao\model\media\ProcessedFileStreamAware; |
40 | use oat\tao\model\media\sourceStrategy\HttpSource; |
41 | use oat\taoItems\model\media\ItemMediaResolver; |
42 | use oat\taoItems\model\media\LocalItemSource; |
43 | use oat\taoQtiItem\model\portableElement\exception\PortableElementException; |
44 | use oat\taoQtiItem\model\portableElement\exception\PortableElementInvalidAssetException; |
45 | use oat\taoQtiItem\model\portableElement\PortableElementService; |
46 | use oat\taoQtiItem\model\qti\AssetParser; |
47 | use oat\taoQtiItem\model\qti\Element; |
48 | use oat\taoQtiItem\model\qti\exception\ExportException; |
49 | use oat\taoQtiItem\model\qti\metadata\exporter\MetadataExporter; |
50 | use oat\taoQtiItem\model\qti\metadata\MetadataService; |
51 | use oat\taoQtiItem\model\qti\Service; |
52 | use Psr\Http\Message\StreamInterface; |
53 | use tao_models_classes_FileNotFoundException; |
54 | use taoItems_models_classes_ItemExporter; |
55 | |
56 | abstract 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 | } |