Resolve language item-related PIP GUI todos
[GitHub/WoltLab/WCF.git] / wcfsetup / install / files / lib / system / package / plugin / AbstractXMLPackageInstallationPlugin.class.php
1 <?php
2 namespace wcf\system\package\plugin;
3 use wcf\system\database\util\PreparedStatementConditionBuilder;
4 use wcf\system\exception\SystemException;
5 use wcf\system\language\LanguageFactory;
6 use wcf\system\package\PackageArchive;
7 use wcf\system\package\PackageInstallationDispatcher;
8 use wcf\system\WCF;
9 use wcf\util\FileUtil;
10 use wcf\util\XML;
11
12 /**
13 * Abstract implementation of a package installation plugin using a XML file.
14 *
15 * @author Alexander Ebert
16 * @copyright 2001-2018 WoltLab GmbH
17 * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
18 * @package WoltLabSuite\Core\System\Package\Plugin
19 */
20 abstract class AbstractXMLPackageInstallationPlugin extends AbstractPackageInstallationPlugin {
21 /**
22 * object editor class name
23 * @var string
24 */
25 public $className = '';
26
27 /**
28 * xml tag name, e.g. 'acpmenuitem'
29 * @var string
30 */
31 public $tagName = '';
32
33 /**
34 * @inheritDoc
35 */
36 public function __construct(PackageInstallationDispatcher $installation, $instruction = []) {
37 parent::__construct($installation, $instruction);
38
39 // autoset 'tableName' property
40 if (empty($this->tableName) && !empty($this->className)) {
41 $this->tableName = call_user_func([$this->className, 'getDatabaseTableAlias']);
42 }
43
44 // autoset 'tagName' property
45 if (empty($this->tagName) && !empty($this->tableName)) {
46 $this->tagName = str_replace('_', '', $this->tableName);
47 }
48 }
49
50 /**
51 * @inheritDoc
52 */
53 public function install() {
54 parent::install();
55
56 // get xml
57 $xml = $this->getXML($this->instruction['value']);
58 $xpath = $xml->xpath();
59
60 // handle delete first
61 if ($this->installation->getAction() == 'update') {
62 $this->deleteItems($xpath);
63 }
64
65 // handle import
66 $this->importItems($xpath);
67
68 // execute cleanup
69 $this->cleanup();
70 }
71
72 /**
73 * @inheritDoc
74 */
75 public function uninstall() {
76 parent::uninstall();
77
78 // execute cleanup
79 $this->cleanup();
80 }
81
82 /**
83 * Deletes items.
84 *
85 * @param \DOMXPath $xpath
86 */
87 protected function deleteItems(\DOMXPath $xpath) {
88 $elements = $xpath->query('/ns:data/ns:delete/ns:'.$this->tagName);
89 $items = [];
90 foreach ($elements as $element) {
91 $data = [
92 'attributes' => [],
93 'elements' => [],
94 'value' => $element->nodeValue
95 ];
96
97 // get attributes
98 $attributes = $xpath->query('attribute::*', $element);
99 foreach ($attributes as $attribute) {
100 $data['attributes'][$attribute->name] = $attribute->value;
101 }
102
103 // get child elements
104 $childNodes = $xpath->query('child::*', $element);
105 foreach ($childNodes as $childNode) {
106 $data['elements'][$childNode->nodeName] = $childNode->nodeValue;
107 }
108
109 $items[] = $data;
110 }
111
112 // delete items
113 if (!empty($items)) {
114 $this->handleDelete($items);
115 }
116 }
117
118 /**
119 * @param \DOMXPath $xpath
120 * @return \DOMNodeList
121 */
122 protected function getImportElements(\DOMXPath $xpath) {
123 return $xpath->query('/ns:data/ns:import/ns:'.$this->tagName);
124 }
125
126 /**
127 * Imports or updates items.
128 *
129 * @param \DOMXPath $xpath
130 */
131 protected function importItems(\DOMXPath $xpath) {
132 foreach ($this->getImportElements($xpath) as $element) {
133 $data = [
134 'attributes' => [],
135 'elements' => [],
136 'nodeValue' => ''
137 ];
138
139 // fetch attributes
140 $attributes = $xpath->query('attribute::*', $element);
141 foreach ($attributes as $attribute) {
142 $data['attributes'][$attribute->name] = $attribute->value;
143 }
144
145 // fetch child elements
146 $items = $xpath->query('child::*', $element);
147 foreach ($items as $item) {
148 $this->getElement($xpath, $data['elements'], $item);
149 }
150
151 // include node value if item does not contain any child elements (eg. pip)
152 if (empty($data['elements'])) {
153 $data['nodeValue'] = $element->nodeValue;
154 }
155
156 // map element data to database fields
157 $data = $this->prepareImport($data);
158
159 // validate item data
160 $this->validateImport($data);
161
162 // try to find an existing item for updating
163 $sqlData = $this->findExistingItem($data);
164
165 // handle items which do not support updating (e.g. cronjobs)
166 if ($sqlData === null) $row = false;
167 else {
168 $statement = WCF::getDB()->prepareStatement($sqlData['sql']);
169 $statement->execute($sqlData['parameters']);
170 $row = $statement->fetchArray();
171 }
172
173 // ensure a valid parameter for import()
174 if ($row === false) $row = [];
175
176 // import items
177 $this->import($row, $data);
178 }
179
180 // fire after import
181 $this->postImport();
182 }
183
184 /**
185 * Sets element value from XPath.
186 *
187 * @param \DOMXPath $xpath
188 * @param array $elements
189 * @param \DOMElement $element
190 */
191 protected function getElement(\DOMXPath $xpath, array &$elements, \DOMElement $element) {
192 $elements[$element->tagName] = $element->nodeValue;
193 }
194
195 /**
196 * Returns i18n values by validating each value against the list of installed
197 * languages, optionally returning only the best matching value.
198 *
199 * @param string[] $values list of values by language code
200 * @param boolean $singleValueOnly true to return only the best matching value
201 * @return string[]|string matching i18n values controller by `$singleValueOnly`
202 * @since 3.0
203 */
204 protected function getI18nValues(array $values, $singleValueOnly = false) {
205 if (empty($values)) {
206 return $singleValueOnly ? '' : [];
207 }
208
209 // check for a value with an empty language code and treat it as 'en' unless 'en' exists
210 if (isset($values[''])) {
211 if (!isset($values['en'])) {
212 $values['en'] = $values[''];
213 }
214
215 unset($values['']);
216 }
217
218 $matchingValues = [];
219 foreach ($values as $languageCode => $value) {
220 if (LanguageFactory::getInstance()->getLanguageByCode($languageCode) !== null) {
221 $matchingValues[$languageCode] = $value;
222 }
223 }
224
225 // no matching value found
226 if (empty($matchingValues)) {
227 if (isset($values['en'])) {
228 // safest route: pick English
229 $matchingValues['en'] = $values['en'];
230 }
231 else if (isset($values[''])) {
232 // fallback: use the value w/o a language code
233 $matchingValues[''] = $values[''];
234 }
235 else {
236 // failsafe: just use the first found value in whatever language
237 $matchingValues = array_splice($values, 0, 1);
238 }
239 }
240
241 if ($singleValueOnly) {
242 if (isset($matchingValues[LanguageFactory::getInstance()->getDefaultLanguage()->languageCode])) {
243 return $matchingValues[LanguageFactory::getInstance()->getDefaultLanguage()->languageCode];
244 }
245
246 return array_shift($matchingValues);
247 }
248
249 return $matchingValues;
250 }
251
252 /**
253 * Inserts or updates new items.
254 *
255 * @param array $row
256 * @param array $data
257 * @return \wcf\data\IStorableObject
258 */
259 protected function import(array $row, array $data) {
260 if (empty($row)) {
261 // create new item
262 $this->prepareCreate($data);
263
264 return call_user_func([$this->className, 'create'], $data);
265 }
266 else {
267 // update existing item
268 $baseClass = call_user_func([$this->className, 'getBaseClass']);
269
270 /** @var \wcf\data\DatabaseObjectEditor $itemEditor */
271 $itemEditor = new $this->className(new $baseClass(null, $row));
272 $itemEditor->update($data);
273
274 return $itemEditor;
275 }
276 }
277
278 /**
279 * Executed after all items would have been imported, use this hook if you've
280 * overwritten import() to disable insert/update.
281 */
282 protected function postImport() { }
283
284 /**
285 * Deletes the given items.
286 *
287 * @param array $items
288 */
289 abstract protected function handleDelete(array $items);
290
291 /**
292 * Prepares import, use this to map xml tags and attributes
293 * to their corresponding database fields.
294 *
295 * @param array $data
296 * @return array
297 */
298 abstract protected function prepareImport(array $data);
299
300 /**
301 * Validates given item, e.g. checking for invalid values. If validation
302 * fails you should throw an exception.
303 *
304 * @param array $data
305 */
306 protected function validateImport(array $data) { }
307
308 /**
309 * Returns an array with a sql query and its parameters to find an existing item for updating
310 * or `null` if updates are not supported.
311 *
312 * @param array $data
313 * @return array|null
314 */
315 abstract protected function findExistingItem(array $data);
316
317 /**
318 * Append additional fields which are not to be updated if a corresponding
319 * item exists but are required for creation.
320 *
321 * Attention: $data is passed by reference
322 *
323 * @param array $data
324 */
325 protected function prepareCreate(array &$data) {
326 $data['packageID'] = $this->installation->getPackageID();
327 }
328
329 /**
330 * Triggered after executing all delete and/or import actions.
331 */
332 protected function cleanup() { }
333
334 /**
335 * Loads the xml file into a string and returns this string.
336 *
337 * @param string $filename
338 * @return XML $xml
339 * @throws SystemException
340 */
341 protected function getXML($filename = '') {
342 if (empty($filename)) {
343 $filename = $this->instruction['value'];
344 }
345
346 // Search the xml-file in the package archive.
347 // Abort installation in case no file was found.
348 if (($fileIndex = $this->installation->getArchive()->getTar()->getIndexByFilename($filename)) === false) {
349 throw new SystemException("xml file '".$filename."' not found in '".$this->installation->getArchive()->getArchive()."'");
350 }
351
352 // Extract acpmenu file and parse XML
353 $xml = new XML();
354 $tmpFile = FileUtil::getTemporaryFilename('xml_');
355 try {
356 $this->installation->getArchive()->getTar()->extract($fileIndex, $tmpFile);
357 $xml->load($tmpFile);
358 }
359 catch (\Exception $e) { // bugfix to avoid file caching problems
360 try {
361 $this->installation->getArchive()->getTar()->extract($fileIndex, $tmpFile);
362 $xml->load($tmpFile);
363 }
364 catch (\Exception $e) {
365 $this->installation->getArchive()->getTar()->extract($fileIndex, $tmpFile);
366 $xml->load($tmpFile);
367 }
368 }
369
370 @unlink($tmpFile);
371 return $xml;
372 }
373
374 /**
375 * Returns the show order value.
376 *
377 * @param integer $showOrder
378 * @param string $parentName
379 * @param string $columnName
380 * @param string $tableNameExtension
381 * @return integer
382 */
383 protected function getShowOrder($showOrder, $parentName = null, $columnName = null, $tableNameExtension = '') {
384 if ($showOrder === null) {
385 // get greatest showOrder value
386 $conditions = new PreparedStatementConditionBuilder();
387 if ($columnName !== null) $conditions->add($columnName." = ?", [$parentName]);
388
389 $sql = "SELECT MAX(showOrder) AS showOrder
390 FROM ".$this->application.WCF_N."_".$this->tableName.$tableNameExtension."
391 ".$conditions;
392 $statement = WCF::getDB()->prepareStatement($sql);
393 $statement->execute($conditions->getParameters());
394 $maxShowOrder = $statement->fetchArray();
395 return (!$maxShowOrder) ? 1 : ($maxShowOrder['showOrder'] + 1);
396 }
397 else {
398 // increase all showOrder values which are >= $showOrder
399 $sql = "UPDATE ".$this->application.WCF_N."_".$this->tableName.$tableNameExtension."
400 SET showOrder = showOrder + 1
401 WHERE showOrder >= ?
402 ".($columnName !== null ? "AND ".$columnName." = ?" : "");
403 $statement = WCF::getDB()->prepareStatement($sql);
404
405 $data = [$showOrder];
406 if ($columnName !== null) $data[] = $parentName;
407
408 $statement->execute($data);
409
410 // return the wanted showOrder level
411 return $showOrder;
412 }
413 }
414
415 /**
416 * @see \wcf\system\package\plugin\IPackageInstallationPlugin::getDefaultFilename()
417 * @since 3.0
418 */
419 public static function getDefaultFilename() {
420 $classParts = explode('\\', get_called_class());
421
422 return lcfirst(str_replace('PackageInstallationPlugin', '', array_pop($classParts))).'.xml';
423 }
424
425 /**
426 * @inheritDoc
427 */
428 public static function isValid(PackageArchive $archive, $instruction) {
429 if (!$instruction) {
430 $defaultFilename = static::getDefaultFilename();
431 if ($defaultFilename) {
432 $instruction = $defaultFilename;
433 }
434 }
435
436 if (preg_match('~\.xml$~', $instruction)) {
437 // check if file actually exists
438 try {
439 if ($archive->getTar()->getIndexByFilename($instruction) === false) {
440 return false;
441 }
442 }
443 catch (SystemException $e) {
444 return false;
445 }
446
447 return true;
448 }
449
450 return false;
451 }
452 }