Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
82.22% |
74 / 90 |
|
50.00% |
4 / 8 |
CRAP | |
0.00% |
0 / 1 |
AbstractQtiConverter | |
82.22% |
74 / 90 |
|
50.00% |
4 / 8 |
36.40 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
convertToQti2 | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
3 | |||
convertRootElementsRecursively | |
60.00% |
15 / 25 |
|
0.00% |
0 / 1 |
18.74 | |||
adjustAttributes | |
70.00% |
7 / 10 |
|
0.00% |
0 / 1 |
5.68 | |||
convertRootElement | |
96.43% |
27 / 28 |
|
0.00% |
0 / 1 |
2 | |||
getRootElement | n/a |
0 / 0 |
n/a |
0 / 0 |
0 | |||||
isTagValid | |
81.82% |
9 / 11 |
|
0.00% |
0 / 1 |
3.05 | |||
canBeConverted | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
4 | |||
isExpectedLengthEquivalent | |
100.00% |
1 / 1 |
|
100.00% |
1 / 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) 2024-2025 (original work) Open Assessment Technologies SA; |
19 | */ |
20 | |
21 | declare(strict_types=1); |
22 | |
23 | namespace oat\taoQtiItem\model\qti\converter; |
24 | |
25 | use common_Logger; |
26 | use DOMAttr; |
27 | use DOMComment; |
28 | use DOMDocument; |
29 | use DOMElement; |
30 | use DOMText; |
31 | use DOMXPath; |
32 | use oat\taoQtiItem\model\ValidationService; |
33 | |
34 | abstract class AbstractQtiConverter |
35 | { |
36 | private const QTI_22_NS = 'http://www.imsglobal.org/xsd/imsqti_v2p2'; |
37 | private const QTI_3_NS = 'http://www.imsglobal.org/xsd/imsqtiasi_v3p0'; |
38 | private const XSI_NAMESPACE = 'http://www.w3.org/2001/XMLSchema-instance'; |
39 | private const XSI_SCHEMA_LOCATION = 'xsi:schemaLocation'; |
40 | private const SCHEMA_LOCATION = 'http://www.imsglobal.org/xsd/imsqti_v2p2 ' . |
41 | 'http://www.imsglobal.org/xsd/qti/qtiv2p2/imsqti_v2p2p1.xsd'; |
42 | private const XML_NS_NAMESPACE = 'http://www.w3.org/2000/xmlns/'; |
43 | private const QUALIFIED_NAME_NS = 'xmlns'; |
44 | private const QUALIFIED_NAME_XSI = self::QUALIFIED_NAME_NS . ':xsi'; |
45 | |
46 | private CaseConversionService $caseConversionService; |
47 | private ValidationService $validationService; |
48 | |
49 | public function __construct(CaseConversionService $caseConversionService, ValidationService $validationService) |
50 | { |
51 | $this->caseConversionService = $caseConversionService; |
52 | $this->validationService = $validationService; |
53 | } |
54 | |
55 | public function convertToQti2(string $filename): void |
56 | { |
57 | // Load the QTI XML document |
58 | $dom = new DOMDocument(); |
59 | $dom->preserveWhiteSpace = false; |
60 | $dom->formatOutput = true; |
61 | $dom->load($filename); |
62 | foreach (iterator_to_array($dom->childNodes) as $child) { |
63 | if ($this->canBeConverted($child)) { |
64 | $this->convertRootElementsRecursively(iterator_to_array($child->childNodes)); |
65 | $this->convertRootElement($child); |
66 | $dom->save($filename); |
67 | } |
68 | } |
69 | } |
70 | |
71 | private function convertRootElementsRecursively(array $children): void |
72 | { |
73 | foreach ($children as $child) { |
74 | if ($child instanceof DOMText || $child instanceof DOMComment) { |
75 | continue; |
76 | } |
77 | |
78 | if ($child instanceof DOMElement) { |
79 | $childNodes = null; |
80 | if ($child->hasChildNodes()) { |
81 | $this->convertRootElementsRecursively(iterator_to_array($child->childNodes)); |
82 | $childNodes = $child->childNodes; |
83 | } |
84 | } |
85 | |
86 | $tagName = $child->tagName; |
87 | // When elements has child we do not want to create literal value |
88 | $nodeValue = $child->childElementCount === 0 ? $child->nodeValue : ''; |
89 | |
90 | $convertedTag = $this->caseConversionService->kebabToCamelCase($tagName); |
91 | // Check if converted tag name is valid against defined QTI 2.2 namespace |
92 | if (!$this->isTagValid($convertedTag)) { |
93 | common_Logger::w(sprintf('Invalid tag name: %s, When importing', $convertedTag)); |
94 | $child->remove(); |
95 | continue; |
96 | } |
97 | |
98 | $newElement = $child->ownerDocument->createElement( |
99 | $convertedTag, |
100 | $nodeValue |
101 | ); |
102 | |
103 | if ($childNodes) { |
104 | foreach (iterator_to_array($childNodes) as $childNode) { |
105 | if ($childNode instanceof DOMElement) { |
106 | $newElement->appendChild($childNode); |
107 | } |
108 | } |
109 | } |
110 | |
111 | $this->adjustAttributes($newElement, $child); |
112 | $child->parentNode->replaceChild($newElement, $child); |
113 | } |
114 | } |
115 | |
116 | private function adjustAttributes(DOMElement $newElement, DOMElement $childNode) |
117 | { |
118 | foreach (iterator_to_array($childNode->attributes) as $attribute) { |
119 | //If attribute name is not equal to nodeName it's most probably namespace attribute |
120 | //This will be hardcoded below therefore we wish to ignore it. |
121 | if ($attribute->name !== $attribute->nodeName) { |
122 | continue; |
123 | } |
124 | |
125 | if ($this->isExpectedLengthEquivalent($attribute)) { |
126 | $newElement->setAttribute('expectedLength', str_replace('qti-input-width-', '', $attribute->value)); |
127 | continue; |
128 | } |
129 | |
130 | // Only replace attribute names from map |
131 | if ($attrReplacement = $this->caseConversionService->kebabToCamelCase($attribute->name)) { |
132 | $newElement->setAttribute($attrReplacement, $attribute->value); |
133 | continue; |
134 | } |
135 | |
136 | $newElement->setAttribute($attribute->name, $attribute->value); |
137 | } |
138 | } |
139 | |
140 | private function convertRootElement(DOMElement $rootElement): void |
141 | { |
142 | $newElement = $rootElement->ownerDocument->createElementNS( |
143 | self::QTI_22_NS, |
144 | $this->caseConversionService->kebabToCamelCase($this->getRootElement()) |
145 | ); |
146 | |
147 | $newElement->setAttributeNS( |
148 | self::XSI_NAMESPACE, |
149 | self::XSI_SCHEMA_LOCATION, |
150 | self::XSI_NAMESPACE |
151 | ); |
152 | |
153 | $newElement->setAttributeNS( |
154 | self::XSI_NAMESPACE, |
155 | self::XSI_SCHEMA_LOCATION, |
156 | self::SCHEMA_LOCATION |
157 | ); |
158 | |
159 | $newElement->setAttributeNS( |
160 | self::XSI_NAMESPACE, |
161 | self::XSI_SCHEMA_LOCATION, |
162 | self::SCHEMA_LOCATION |
163 | ); |
164 | |
165 | $newElement->setAttributeNS( |
166 | self::XML_NS_NAMESPACE, |
167 | self::QUALIFIED_NAME_XSI, |
168 | self::XSI_NAMESPACE |
169 | ); |
170 | |
171 | foreach (iterator_to_array($rootElement->childNodes) as $childNode) { |
172 | $newElement->appendChild($childNode); |
173 | } |
174 | |
175 | $this->adjustAttributes($newElement, $rootElement); |
176 | $rootElement->parentNode->replaceChild($newElement, $rootElement); |
177 | } |
178 | |
179 | abstract protected function getRootElement(): string; |
180 | |
181 | private function isTagValid(string $convertedTag): bool |
182 | { |
183 | $validationSchema = $this->validationService->getContentValidationSchema(self::QTI_22_NS); |
184 | foreach ($validationSchema as $schema) { |
185 | $xsdDom = new DOMDocument(); |
186 | $xsdDom->load($schema); |
187 | $xpath = new DOMXPath($xsdDom); |
188 | $xpath->registerNamespace('xs', 'http://www.w3.org/2001/XMLSchema'); |
189 | $elements = $xpath->query(sprintf("//xs:element[@name='%s']", $convertedTag)); |
190 | |
191 | if ($elements->count() === 0) { |
192 | return false; |
193 | } |
194 | |
195 | return true; |
196 | } |
197 | |
198 | return false; |
199 | } |
200 | |
201 | /** |
202 | * We can only apply conversion into root element equal to defined root element const |
203 | * We should ignore other dom object other DOMElement |
204 | * We should only apply if the namespace is equal to QTI 3.0 |
205 | */ |
206 | private function canBeConverted(mixed $child): bool |
207 | { |
208 | return $child instanceof DOMElement |
209 | && $child->tagName === $this->getRootElement() |
210 | && $child->getAttributeNode('xmlns') !== null |
211 | && $child->getAttributeNode('xmlns')->nodeValue === self::QTI_3_NS; |
212 | } |
213 | |
214 | private function isExpectedLengthEquivalent(DOMAttr $attribute): bool |
215 | { |
216 | return $attribute->name === 'class' && str_contains($attribute->value, 'qti-input-width-'); |
217 | } |
218 | } |