Textpattern | PHP Cross Reference | Content Management Systems |
Description: Skin
1 <?php 2 3 /* 4 * Textpattern Content Management System 5 * https://textpattern.com/ 6 * 7 * Copyright (C) 2020 The Textpattern Development Team 8 * 9 * This file is part of Textpattern. 10 * 11 * Textpattern is free software; you can redistribute it and/or 12 * modify it under the terms of the GNU General Public License 13 * as published by the Free Software Foundation, version 2. 14 * 15 * Textpattern is distributed in the hope that it will be useful, 16 * but WITHOUT ANY WARRANTY; without even the implied warranty of 17 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 18 * GNU General Public License for more details. 19 * 20 * You should have received a copy of the GNU General Public License 21 * along with Textpattern. If not, see <https://www.gnu.org/licenses/>. 22 */ 23 24 /** 25 * Skin 26 * 27 * Manage Skins and their dependencies. 28 * 29 * @since 4.7.0 30 * @package Skin 31 */ 32 33 namespace Textpattern\Skin; 34 35 class Skin extends CommonBase implements SkinInterface 36 { 37 /** 38 * Skin assets related objects. 39 * 40 * @var array Page, Form and CSS class objects. 41 * @see setAssets(). 42 */ 43 44 private $assets; 45 46 /** 47 * Class related main file. 48 * 49 * @var string Filename. 50 * @see getFile(). 51 */ 52 53 protected static $filename = 'manifest.json'; 54 55 /** 56 * {@inheritdoc} 57 */ 58 59 protected static $extension = 'json'; 60 61 /** 62 * Importable skins. 63 * 64 * @var array Associative array of skin names and their infos from JSON files 65 * @see setUploaded(), getUploaded(). 66 */ 67 68 protected $uploaded; 69 70 /** 71 * Class related directory path. 72 * 73 * @var string Path. 74 * @see setDirPath(), getDirPath(). 75 */ 76 77 protected $dirPath; 78 79 /** 80 * {@inheritdoc} 81 */ 82 83 public function __construct() 84 { 85 parent::__construct(); 86 } 87 88 public function __toString() 89 { 90 return $this->getName(); 91 } 92 93 protected function mergeResults($asset, $status) 94 { 95 $this->results = array_merge_recursive($this->getResults(), $asset->getResults($status)); 96 97 return $this; 98 } 99 100 /** 101 * $dirPath property setter. 102 * 103 * @param string $path Path (default: get_pref('path_to_site').DS.get_pref('skin_dir')). 104 * @return string $this->dirPath 105 */ 106 107 public function setDirPath($path = null) 108 { 109 $path !== null or $path = get_pref('path_to_site').DS.get_pref($this->getEvent().'_dir'); 110 111 $this->dirPath = rtrim($path, DS); 112 $this->uploaded = null; 113 114 return $this->getDirPath(); 115 } 116 117 /** 118 * $dirPath property getter 119 * 120 * @return string $this->dirPath 121 */ 122 123 protected function getDirPath() 124 { 125 $this->dirPath !== null or $this->setDirPath(); 126 127 return $this->dirPath; 128 } 129 130 /** 131 * $assets property setter. 132 * 133 * @param array $pages Page names to work with; 134 * @param array $forms Form names to work with; 135 * @param array $styles CSS names to work with. 136 * @return object $this The current class object (chainable) 137 */ 138 139 public function setAssets($pages = null, $forms = null, $styles = null) 140 { 141 $assets = array( 142 'Page' => $pages, 143 'Form' => $forms, 144 'Css' => $styles, 145 ); 146 147 foreach ($assets as $class => $assets) { 148 $this->assets[] = \Txp::get('Textpattern\Skin\\'.$class, $this)->setNames($assets); 149 } 150 151 return $this; 152 } 153 154 /** 155 * $assets property getter. 156 * 157 * @return array $this->$assets 158 */ 159 160 protected function getAssets() 161 { 162 $this->assets !== null or $this->setAssets(); 163 164 return $this->assets; 165 } 166 167 /** 168 * $infos and $name properties setter. 169 * 170 * @param string $name Skin name; 171 * @param string $title Skin title; 172 * @param string $version Skin version; 173 * @param string $description Skin description; 174 * @param string $author Skin author; 175 * @param string $author_uri Skin author URL; 176 * @return object $this The current class object (chainable). 177 */ 178 179 public function setInfos( 180 $name, 181 $title = null, 182 $version = null, 183 $description = null, 184 $author = null, 185 $author_uri = null 186 ) { 187 $name = $this->setName($name)->getName(); 188 189 $title or $title = ucfirst($name); 190 191 $this->infos = compact('name', 'title', 'version', 'description', 'author', 'author_uri'); 192 193 return $this; 194 } 195 196 /** 197 * Get a $dir property value related subdirectory path. 198 * 199 * @param string $name Directory(/skin) name (default: $this->getName()). 200 * @return string The Path 201 */ 202 203 public function getSubdirPath($name = null) 204 { 205 $name !== null or $name = $this->getName(); 206 207 return $this->getDirPath().DS.$name; 208 } 209 210 /** 211 * $file property getter. 212 * 213 * @return string self::$filename. 214 */ 215 216 protected static function getFilename() 217 { 218 return self::$filename; 219 } 220 221 /** 222 * Get the $file property value related path. 223 * 224 * @return string Path. 225 */ 226 227 protected function getFilePath() 228 { 229 return $this->getSubdirPath().DS.self::getFilename(); 230 } 231 232 /** 233 * Get and complete the skin related file contents. 234 * 235 * @return array Associative array of JSON fields and their related values / fallback values. 236 */ 237 238 protected function getFileContents() 239 { 240 $contents = json_decode(file_get_contents($this->getFilePath()), true); 241 242 $contents === null or $contents = $this->parseInfos($contents); 243 244 return $contents; 245 } 246 247 /** 248 * Parse a skin related infos. 249 * 250 * @return array $infos Associative array of fields and their related values / fallback values. 251 */ 252 253 protected function parseInfos($infos) 254 { 255 extract($infos); 256 257 !empty($title) or $title = ucfirst($this->getName()); 258 !empty($version) or $version = gTxt('unknown'); 259 !empty($description) or $description = ''; 260 !empty($author) or $author = gTxt('unknown'); 261 !empty($author_uri) or $author_uri = ''; 262 263 return compact('title', 'version', 'description', 'author', 'author_uri'); 264 } 265 266 /** 267 * $sections property getter. 268 * 269 * @param array Section names. 270 */ 271 272 protected function getSections($skin = null) 273 { 274 $skin = doSlash(is_string($skin) ? $skin : $this->getName()); 275 $event = $this->getEvent(); 276 277 return array_values( 278 safe_column( 279 'name', 280 'txp_section', 281 "$event ='$skin' OR dev_{$event} ='$skin'" 282 ) 283 ); 284 } 285 286 /** 287 * Update the txp_section table. 288 * 289 * @param string $set The SET clause (default: "skin = '".doSlash($this->getName())."'") 290 * @param string $where The WHERE clause (default: "skin = '".doSlash($this->getBase())."'") 291 * @return bool FALSE on error. 292 */ 293 294 public function updateSections($set = null, $where = null, $dev = false) 295 { 296 $event = ($dev ? "{$dev}_" : '').$this->getEvent(); 297 298 $set !== null or $set = $event." = '".doSlash($this->getName())."'"; 299 300 if ($where === null) { 301 $base = $this->getBase(); 302 303 $where = $base ? $event." = '".doSlash($base)."'" : '1 = 1'; 304 } 305 306 return safe_update('txp_section', $set, $where); 307 } 308 309 /** 310 * {@inheritdoc} 311 */ 312 313 public function getEditing() 314 { 315 $editing = get_pref($this->getEvent().'_editing', '', true); 316 317 if (!$editing) { 318 $installed = $this->getInstalled(); 319 320 reset($installed); 321 322 $editing = $this->setEditing(key($installed)); 323 } 324 325 return $editing; 326 } 327 328 /** 329 * {@inheritdoc} 330 */ 331 332 public function setEditing($name = null) 333 { 334 global $prefs; 335 336 $event = $this->getEvent(); 337 338 $name !== null or $name = $this->getName(); 339 $prefs[$event.'_editing'] = $name; 340 341 set_pref($event.'_editing', $name, $event, PREF_HIDDEN, 'text_input', 0, PREF_PRIVATE); 342 343 return $this->getEditing(); 344 } 345 346 /** 347 * Create a file in the $dir property value related directory. 348 * 349 * @param string $pathname The file related path (default: $this->getName().DS.self::getFilename()). 350 * @param mixed $contents The file related contents as as a string or 351 * as an associative array for a .json file 352 * (uses the $infos property related array). 353 * @return bool Written octets number or FALSE on error. 354 */ 355 356 protected function createFile($pathname = null, $contents = null) 357 { 358 $pathname !== null or $pathname = $this->getName().DS.self::getFilename(); 359 360 if ($contents === null) { 361 $contents = array_merge( 362 $this->getInfos(), 363 array('txp-type' => 'textpattern-theme') 364 ); 365 366 unset($contents['name']); 367 } 368 369 if (pathinfo($pathname, PATHINFO_EXTENSION) === 'json') { 370 $contents = json_encode($contents, TEXTPATTERN_JSON | JSON_PRETTY_PRINT); 371 } 372 373 return file_put_contents($this->getDirPath().DS.$pathname, $contents); 374 } 375 376 /** 377 * $uploaded property setter. 378 * 379 * @return object $this The current class object (chainable). 380 */ 381 382 protected function setUploaded() 383 { 384 $this->uploaded = array(); 385 $files = $this->getFiles(array(self::getFilename()), 1); 386 387 if ($files) { 388 foreach ($files as $file) { 389 $name = basename($file->getPath()); 390 391 if ($name === self::sanitize($name)) { 392 $infos = $file->getJSONContents(); 393 394 if ($infos && $infos['txp-type'] === 'textpattern-theme') { 395 $this->uploaded[$name] = $this->setName($name)->parseInfos($infos); 396 } 397 } 398 } 399 } 400 401 return $this; 402 } 403 404 /** 405 * {@inheritdoc} 406 */ 407 408 public function getUploaded($expanded = true) 409 { 410 $this->uploaded !== null or $this->setUploaded(); 411 412 if (!$expanded) { 413 $contracted = array(); 414 415 foreach ($this->uploaded as $name => $infos) { 416 $contracted[$name] = $infos['title'] . ' ('.$infos['version'].')'; 417 } 418 419 return $contracted; 420 } 421 422 return $this->uploaded; 423 } 424 425 /** 426 * $installed property merger. 427 * 428 * @param array $this->installed. 429 */ 430 431 protected function mergeInstalled($skins) 432 { 433 $this->installed = array_merge($this->getInstalled(), $skins); 434 435 return $this->getInstalled(); 436 } 437 438 /** 439 * $installed property remover. 440 * 441 * @return array $this->installed. 442 */ 443 444 protected function removeInstalled($names) 445 { 446 $this->installed = array_diff_key( 447 $this->getInstalled(), 448 array_fill_keys($names, '') 449 ); 450 451 return $this->getInstalled(); 452 } 453 454 /** 455 * {@inheritdoc} 456 */ 457 458 protected function getTableData($criteria, $sortSQL, $offset, $limit) 459 { 460 $assets = array('section', 'page', 'form', 'css'); 461 $things = array('*'); 462 $table = $this->getTable(); 463 464 foreach ($assets as $asset) { 465 $things[] = '(SELECT COUNT(*) ' 466 .'FROM '.safe_pfx_j('txp_'.$asset).' ' 467 .'WHERE txp_'.$asset.'.'.$this->getEvent().' = '.$table.'.name) ' 468 .$asset.'_count'; 469 } 470 471 $things[] = '(SELECT COUNT(*) ' 472 .'FROM '.safe_pfx_j('txp_section').' ' 473 .'WHERE dev_'.$this->getEvent().' = '.$table.'.name) ' 474 .'dev_section_count'; 475 476 return safe_rows_start( 477 implode(', ', $things), 478 $table, 479 $criteria.' order by '.$sortSQL.' limit '.$offset.', '.$limit 480 ); 481 } 482 483 /** 484 * Create/CreateFrom a single skin (and its related assets) 485 * Merges results in the related property. 486 * 487 * @return object $this The current object (chainable). 488 */ 489 490 public function create() 491 { 492 $event = $this->getEvent(); 493 $infos = $this->getInfos(); 494 $name = $infos['name']; 495 $base = $this->getBase(); 496 $callbackExtra = compact('infos', 'base'); 497 $done = false; 498 499 callback_event('txp.'.$event, 'create', 1, $callbackExtra); 500 501 if (empty($name)) { 502 $this->mergeResult($event.'_name_invalid', $name); 503 } elseif ($base && !$this->isInstalled($base)) { 504 $this->mergeResult($event.'_unknown', $base); 505 } elseif ($this->isInstalled()) { 506 $this->mergeResult($event.'_already_exists', $name); 507 } elseif (is_dir($nameDirPath = $this->getSubdirPath())) { 508 // Create a skin which would already have a related directory could cause conflicts. 509 $this->mergeResult($event.'_already_exists', $nameDirPath); 510 } elseif (!$this->createRow()) { 511 $this->mergeResult($event.'_creation_failed', $name); 512 } else { 513 $this->mergeResult($event.'_created', $name, 'success'); 514 515 // Start working with the skin related assets. 516 foreach ($this->getAssets() as $assetModel) { 517 if ($base) { 518 $this->setName($base); 519 $rows = $assetModel->getRows(); 520 $this->setName($name); 521 } else { 522 $rows = null; 523 } 524 525 if (!$assetModel->createRows($rows)) { 526 $assetsfailed = true; 527 528 $this->mergeResult($assetModel->getEvent().'_creation_failed', $name); 529 } 530 } 531 532 // If the assets related process did not failed; that is a success… 533 isset($assetsfailed) or $done = $name; 534 } 535 536 callback_event('txp.'.$event, 'create', 0, $callbackExtra + compact('done')); 537 538 return $this; // Chainable. 539 } 540 541 /** 542 * Update a single skin (and its related dependencies) 543 * Merges results in the related property. 544 * 545 * @return object $this The current object (chainable). 546 */ 547 548 public function update() 549 { 550 $event = $this->getEvent(); 551 $infos = $this->getInfos(); 552 $name = $infos['name']; 553 $base = $this->getBase(); 554 $callbackExtra = compact('infos', 'base'); 555 $done = null; 556 $ready = false; 557 558 callback_event('txp.'.$event, 'update', 1, $callbackExtra); 559 560 if (empty($name)) { 561 $this->mergeResult($event.'_name_invalid', $name); 562 } elseif (!$this->isInstalled($base)) { 563 $this->mergeResult($event.'_unknown', $base); 564 } elseif ($base !== $name && $this->isInstalled()) { 565 $this->mergeResult($event.'_already_exists', $name); 566 } elseif (is_dir($nameDirPath = $this->getSubdirPath()) && $base !== $name) { 567 // Rename the skin with a name which would already have a related directory could cause conflicts. 568 $this->mergeResult($event.'_already_exists', $nameDirPath); 569 } elseif (!$this->updateRow()) { 570 $this->mergeResult($event.'_update_failed', $base); 571 $locked = $base; 572 } else { 573 $this->mergeResult($event.'_updated', $name, 'success'); 574 $ready = true; 575 $locked = $base; 576 $baseDirPath = $this->getSubdirPath($base); 577 578 // Rename the skin related directory to allow new updates from files. 579 if (is_dir($baseDirPath) && !@rename($baseDirPath, $nameDirPath)) { 580 $this->mergeResult('path_renaming_failed', $base, 'warning'); 581 } else { 582 $locked = $name; 583 } 584 } 585 586 if ($ready) { 587 // Update skin related sections. 588 if ($sections = $this->getSections($base)) { 589 $updated = $this->updateSections(); 590 $updated = $this->updateSections(null, null, 'dev') && $updated; 591 $updated or $this->mergeResult($event.'_related_sections_update_failed', array($base => $sections)); 592 } 593 594 // update the skin_editing pref if needed. 595 $this->getEditing() !== $base or $this->setEditing(); 596 597 // Start working with the skin related assets. 598 $assetUpdateSet = $event." = '".doSlash($this->getName())."'"; 599 $assetUpdateWhere = $event." = '".doSlash($this->getBase())."'"; 600 601 foreach ($this->getAssets() as $assetModel) { 602 if (!$assetModel->updateRow($assetUpdateSet, $assetUpdateWhere)) { 603 $assetsFailed = true; 604 $this->mergeResult($assetModel->getEvent().'_update_failed', $base); 605 } 606 } 607 608 // If the assets related process did not failed; that is a success… 609 isset($assetsFailed) or $done = $name; 610 } 611 612 callback_event('txp.'.$event, 'update', 0, $callbackExtra + compact('done')); 613 614 return $this; // Chainable 615 } 616 617 /** 618 * Duplicate multiple skins (and their related $assets) 619 * Merges results in the related property. 620 * 621 * @return object $this The current object (chainable). 622 */ 623 624 public function duplicate() 625 { 626 $event = $this->getEvent(); 627 $names = $this->getNames(); 628 $callbackExtra = compact('names'); 629 $ready = $done = array(); 630 631 callback_event('txp.'.$event, 'duplicate', 1, $callbackExtra); 632 633 foreach ($names as $name) { 634 $nameDirPath = $this->setName($name)->getSubdirPath(); 635 $copy = $name.'_copy'; 636 637 if (!$this->isInstalled()) { 638 $this->mergeResult($event.'_unknown', $name); 639 } elseif ($this->isInstalled($copy)) { 640 $this->mergeResult($event.'_already_exists', $copy); 641 } elseif (is_dir($copyPath = $this->getSubdirPath($copy))) { 642 $this->mergeResult($event.'_already_exists', $copyPath); 643 } else { 644 $ready[] = $name; 645 } 646 } 647 648 if ($ready) { 649 $rows = $this->getRows( 650 "*", 651 "name IN ('".implode("', '", array_map('doSlash', $ready))."')" 652 ); 653 654 if (!$rows) { 655 $this->mergeResult($event.'_not_found', $ready); 656 } else { 657 foreach ($rows as $row) { 658 extract($row); 659 660 $copy = $name.'_copy'; 661 $copyTitle = $title.' (copy)'; 662 663 $this->setInfos($copy, $copyTitle, $version, $description, $author, $author_uri); 664 665 if (!$this->createRow()) { 666 $this->mergeResult($event.'_creation_failed', $copy); 667 } else { 668 $this->mergeResult($event.'_created', $copy, 'success'); 669 $this->mergeInstalled(array($copy => $copyTitle)); 670 671 // Start working with the skin related assets. 672 foreach ($this->getAssets() as $assetModel) { 673 $this->setName($name); 674 $assetString = $assetModel->getEvent(); 675 $assetRows = $assetModel->getRows(); 676 677 if (!$assetRows) { 678 $deleteExtraFiles = true; 679 680 $this->mergeResult($assetString.'_not_found', array($skin => $nameDirPath)); 681 } elseif ($this->setName($copy) && !$assetModel->createRows($assetRows)) { 682 $deleteExtraFiles = true; 683 684 $this->mergeResult($assetString.'_creation_failed', $copy); 685 } 686 } 687 688 $this->setName($name); // Be sure to restore the right $name. 689 690 // If the assets related process did not failed; that is a success… 691 isset($deleteExtraFiles) or $done[] = $name; 692 } 693 } 694 } 695 } 696 697 callback_event('txp.'.$event, 'duplicate', 0, $callbackExtra + compact('done')); 698 699 return $this; // Chainable 700 } 701 702 /** 703 * {@inheritdoc} 704 */ 705 706 public function import($sync = false, $override = false) 707 { 708 $event = $this->getEvent(); 709 $syncPref = 'skin_delete_from_database'; 710 $sync == $this->getSyncPref($syncPref) or $this->switchSyncPref($syncPref); 711 $names = $this->getNames(); 712 $callbackExtra = compact('names', 'sync'); 713 $done = array(); 714 715 callback_event('txp.'.$event, 'import', 1, $callbackExtra); 716 717 foreach ($names as $name) { 718 $this->setName($name); 719 $this->setBase($name); 720 721 $isInstalled = $this->isInstalled(); 722 $isInstalled or $sync = $override = false; // Avoid useless work. 723 724 if (!$override && $isInstalled) { 725 $this->mergeResult($event.'_already_exists', $name); 726 } elseif (!is_readable($filePath = $this->getFilePath())) { 727 $this->mergeResult('path_not_readable', $filePath); 728 } else { 729 $skinInfos = array_merge(array('name' => $name), $this->getFileContents()); 730 731 if (!$skinInfos) { 732 $this->mergeResult('invalid_json', $filePath); 733 } else { 734 extract($skinInfos); 735 736 $this->setInfos($name, $title, $version, $description, $author, $author_uri); 737 738 if (!$override && !$this->createRow()) { 739 $this->mergeResult($event.'_import_failed', $name); 740 } elseif ($override && !$this->updateRow()) { 741 $this->mergeResult($event.'_import_failed', $name); 742 } else { 743 $this->mergeResult($event.'_imported', $name, 'success'); 744 $this->mergeInstalled(array($name => $title)); 745 746 // Start working with the skin related assets. 747 foreach ($this->getAssets() as $asset) { 748 $asset->import($sync, $override); 749 750 if (is_array($asset->getMessage())) { 751 $assetFailed = true; 752 753 $this->mergeResults($asset, array('warning', 'error')); 754 } 755 } 756 } 757 758 // If the assets related process did not failed; that is a success… 759 isset($assetFailed) or $done[] = $name; 760 } 761 } 762 } 763 764 callback_event('txp.'.$event, 'import', 0, $callbackExtra + compact('done')); 765 766 return $this; 767 } 768 769 /** 770 * {@inheritdoc} 771 */ 772 773 public function export($sync = false, $override = false) 774 { 775 $syncPref = 'skin_delete_from_disk'; 776 $sync == $this->getSyncPref($syncPref) or $this->switchSyncPref($syncPref); 777 778 $event = $this->getEvent(); 779 $names = $this->getNames(); 780 $callbackExtra = compact('names', 'sync'); 781 $ready = $done = array(); 782 783 callback_event('txp.'.$event, 'export', 1, $callbackExtra); 784 785 foreach ($names as $name) { 786 $this->setName($name); 787 788 $nameDirPath = $this->getSubdirPath(); 789 790 if (!is_writable($nameDirPath)) { 791 $sync = false; 792 $override = false; 793 } 794 795 if (!self::isExportable($name)) { 796 $this->mergeResult($event.'_unsafe_name', $name); 797 } elseif (!$override && is_dir($nameDirPath)) { 798 $this->mergeResult($event.'_already_exists', $nameDirPath); 799 } elseif (!is_dir($nameDirPath) && !@mkdir($nameDirPath)) { 800 $this->mergeResult('path_not_writable', $nameDirPath); 801 } else { 802 $ready[] = $name; 803 } 804 } 805 806 if ($ready) { 807 $rows = $this->getRows( 808 "*", 809 "name IN ('".implode("', '", array_map('doSlash', $ready))."')" 810 ); 811 812 if (!$rows) { 813 $this->mergeResult($event.'_unknown', $names); 814 } else { 815 foreach ($rows as $row) { 816 extract($row); 817 818 $this->setInfos($name, $title, $version, $description, $author, $author_uri); 819 820 if ($this->createFile() === false) { 821 $this->mergeResult($event.'_export_failed', $name); 822 } else { 823 $this->mergeResult($event.'_exported', $name, 'success'); 824 825 foreach ($this->getAssets() as $asset) { 826 $asset->export($sync, $override); 827 828 if (is_array($asset->getMessage())) { 829 $assetFailed = true; 830 831 $this->mergeResults($asset, array('warning', 'error')); 832 } 833 } 834 835 isset($assetFailed) or $done[] = $name; 836 } 837 } 838 } 839 } 840 841 callback_event('txp.'.$event, 'export', 0, $callbackExtra + compact('done')); 842 843 return $this; 844 } 845 846 /** 847 * Delete multiple skins (and their related $assets + directories if empty) 848 * Merges results in the related property. 849 * 850 * @return object $this The current object (chainable). 851 */ 852 853 public function delete($sync = false) 854 { 855 $syncPref = 'skin_delete_entirely'; 856 $sync == $this->getSyncPref($syncPref) or $this->switchSyncPref($syncPref); 857 858 $event = $this->getEvent(); 859 $names = $this->getNames(); 860 $callbackExtra = compact('names', 'sync'); 861 $ready = $done = array(); 862 863 callback_event('txp.'.$event, 'delete', 1, $callbackExtra); 864 865 foreach ($names as $name) { 866 $isDir = is_dir($this->getSubdirPath($name)); 867 868 if (!$this->setName($name)->isInstalled()) { 869 $this->mergeResult($event.'_unknown', $name); 870 } elseif ($sections = $this->getSections($name)) { 871 $this->mergeResult($event.'_in_use', array($name => $sections)); 872 } else { 873 /** 874 * Start working with the skin related assets. 875 * Done first as assets won't be accessible 876 * once their parent skin will be deleted. 877 */ 878 $assetFailed = false; 879 880 foreach ($this->getAssets() as $assetModel) { 881 if (!$assetModel->deleteRows()) { 882 $assetFailed = true; 883 $this->mergeResult($assetModel->getEvent().'_deletion_failed', $name); 884 } elseif ($sync && $isDir) { 885 $notDeleted = $assetModel->deleteExtraFiles(); 886 887 if ($notDeleted) { 888 $this->mergeResult($assetModel->getEvent().'_files_deletion_failed', array($name => $notDeleted)); 889 } 890 } 891 } 892 893 $assetFailed or $ready[] = $name; 894 } 895 } 896 897 if ($ready) { 898 if ($this->deleteRows("name IN ('".implode("', '", array_map('doSlash', $ready))."')")) { 899 $done = $ready; 900 901 $this->removeInstalled($ready); 902 903 if (in_array($this->getEditing(), $ready)) { 904 $default = $this->getDefault(); 905 906 !$default or $this->setEditing($default); 907 } 908 909 $this->mergeResult($event.'_deleted', $ready, 'success'); 910 911 // Remove all skins files and directories if needed. 912 if ($sync) { 913 $notDeleted = $this->deleteFiles($ready); 914 915 !$notDeleted or $this->mergeResult($event.'_files_deletion_failed', $notDeleted); 916 } 917 918 update_lastmod($event.'.delete', $ready); 919 } else { 920 $this->mergeResult($event.'_deletion_failed', $ready); 921 } 922 } 923 924 callback_event('txp.'.$event, 'delete', 0, $callbackExtra + compact('done')); 925 926 return $this; 927 } 928 929 /** 930 * Delete Files from the $dir property value related directory. 931 * 932 * @param string $names directory/file names. 933 * @return bool 0 on error. 934 */ 935 936 protected function deleteFiles($names = null) 937 { 938 $notRemoved = array(); 939 940 foreach ($names as $name) { 941 if (is_dir($this->getSubdirPath($name))) { 942 $filePath = $this->getFilePath(); 943 944 if (file_exists($filePath) && !unlink($filePath)) { 945 $notRemoved[$name][] = $filePath; 946 } 947 948 $subdirPath = $this->getSubdirPath(); 949 $isDirEmpty = self::isDirEmpty($subdirPath); 950 951 if (!isset($notRemoved[$name]) && ($isDirEmpty && !@rmdir($subdirPath) || !$isDirEmpty)) { 952 $notRemoved[$name][] = $subdirPath; 953 } 954 } 955 } 956 957 return $notRemoved; 958 } 959 960 /** 961 * Control the admin tab. 962 */ 963 964 public function admin() 965 { 966 if (!defined('txpinterface')) { 967 die('txpinterface is undefined.'); 968 } 969 970 global $event, $step; 971 972 if ($event === $this->getEvent()) { 973 require_privs($event); 974 975 bouncer($step, array( 976 $event.'_change_pageby' => true, // Prefixed to make it work with the paginator… 977 'list' => false, 978 'edit' => false, 979 'save' => true, 980 'import' => false, 981 'multi_edit' => true, 982 )); 983 984 switch ($step) { 985 case 'save': 986 $infos = array_map('assert_string', psa(array( 987 'name', 988 'title', 989 'old_name', 990 'old_title', 991 'version', 992 'description', 993 'author', 994 'author_uri', 995 'copy', 996 ))); 997 998 extract($infos); 999 1000 if ($old_name) { 1001 if ($copy) { 1002 $name !== $old_name or $name .= '_copy'; 1003 $title !== $old_title or $title .= ' (copy)'; 1004 1005 $this->setInfos($name, $title, $version, $description, $author, $author_uri) 1006 ->setBase($old_name) 1007 ->create(); 1008 } else { 1009 $this->setInfos($name, $title, $version, $description, $author, $author_uri) 1010 ->setBase($old_name) 1011 ->update(); 1012 } 1013 } else { 1014 $title !== '' or $title = ucfirst($name); 1015 $author !== '' or $author = substr(cs('txp_login_public'), 10); 1016 $version !== '' or $version = '0.0.1'; 1017 1018 $this->setInfos($name, $title, $version, $description, $author, $author_uri) 1019 ->create(); 1020 } 1021 break; 1022 case 'multi_edit': 1023 extract(psa(array( 1024 'edit_method', 1025 'selected', 1026 'sync', 1027 ))); 1028 1029 if (!$selected || !is_array($selected)) { 1030 return $this->render($step); 1031 } 1032 1033 $this->setNames(ps('selected')); 1034 1035 switch ($edit_method) { 1036 case 'export': 1037 $this->export($sync, true); 1038 break; 1039 case 'duplicate': 1040 $this->duplicate(); 1041 break; 1042 case 'import': 1043 $this->import($sync, true); 1044 break; 1045 case 'delete': 1046 $this->delete($sync); 1047 break; 1048 } 1049 break; 1050 case 'edit': 1051 break; 1052 case 'import': 1053 $this->setNames(array(ps('skins')))->import(); 1054 break; 1055 case $event.'_change_pageby': 1056 $this->getPaginator()->change(); 1057 break; 1058 } 1059 1060 return $this->render($step); 1061 } 1062 } 1063 1064 /** 1065 * Render (echo) the $step related admin tab. 1066 * 1067 * @param string $step 1068 */ 1069 1070 public function render($step) 1071 { 1072 $message = $this->getMessage(); 1073 1074 if ($step === 'edit') { 1075 echo $this->getEditForm($message); 1076 } else { 1077 echo $this->getList($message); 1078 } 1079 } 1080 1081 /** 1082 * {@inheritdoc} 1083 */ 1084 1085 protected function getList($message = '') 1086 { 1087 $event = $this->getEvent(); 1088 $table = $this->getTable(); 1089 1090 pagetop(gTxt('tab_'.$event), $message); 1091 1092 extract(gpsa(array( 1093 'page', 1094 'sort', 1095 'dir', 1096 'crit', 1097 'search_method', 1098 ))); 1099 1100 if ($sort === '') { 1101 $sort = get_pref($event.'_sort_column', 'name'); 1102 } else { 1103 $sortOpts = array( 1104 'title', 1105 'version', 1106 'author', 1107 'section_count', 1108 'page_count', 1109 'form_count', 1110 'css_count', 1111 'name', 1112 ); 1113 1114 in_array($sort, $sortOpts) or $sort = 'name'; 1115 1116 set_pref($event.'_sort_column', $sort, $event, PREF_HIDDEN, '', 0, PREF_PRIVATE); 1117 } 1118 1119 if ($dir === '') { 1120 $dir = get_pref($event.'_sort_dir', 'desc'); 1121 } else { 1122 $dir = ($dir == 'asc') ? 'asc' : 'desc'; 1123 1124 set_pref($event.'_sort_dir', $dir, $event, PREF_HIDDEN, '', 0, PREF_PRIVATE); 1125 } 1126 1127 $searchOpts = array(); 1128 1129 foreach (array('name', 'title', 'description', 'author') as $option) { 1130 $searchOpts[$option] = array( 1131 'column' => $table.'.'.$option, 1132 'label' => gTxt($option), 1133 ); 1134 } 1135 1136 $search = $this->getSearchFilter($searchOpts); 1137 1138 list($criteria, $crit, $search_method) = $search->getFilter(); 1139 1140 $total = $this->countRows($criteria); 1141 $limit = $this->getPaginator()->getLimit(); 1142 1143 list($page, $offset, $numPages) = pager($total, $limit, $page); 1144 1145 $table = \Txp::get('Textpattern\Admin\Table'); 1146 1147 return $table->render( 1148 compact('total', 'crit') + array('help' => 'skin_overview'), 1149 $this->getSearchBlock($search), 1150 $this->getCreateBlock(), 1151 $this->getContentBlock(compact('offset', 'limit', 'total', 'criteria', 'crit', 'search_method', 'page', 'sort', 'dir')), 1152 $this->getFootBlock(compact('limit', 'numPages', 'total', 'crit', 'search_method', 'page', 'sort', 'dir')) 1153 ); 1154 } 1155 1156 /** 1157 * Get the admin related search form wrapped in its div. 1158 * 1159 * @param object $search Textpattern\Search\Filter class object. 1160 * @return HTML 1161 */ 1162 1163 protected function getSearchBlock($search) 1164 { 1165 $event = $this->getEvent(); 1166 1167 return n.tag( 1168 $search->renderForm($event, array('placeholder' => 'search_skins')), 1169 'div', 1170 array( 1171 'class' => 'txp-layout-4col-3span', 1172 'id' => $event.'_control', 1173 ) 1174 ); 1175 } 1176 1177 /** 1178 * Get the .txp-control-panel div. 1179 * 1180 * @return HTML div containing the 'Create' button and the import form. 1181 * @see getImportForm(), getCreateButton(). 1182 */ 1183 1184 protected function getCreateBlock() 1185 { 1186 if (has_privs($this->getEvent().'.edit')) { 1187 return tag( 1188 $this->getCreateButton().$this->getImportForm(), 1189 'div', 1190 array('class' => 'txp-control-panel txp-async-update', 'id' => 'skin_control_panel') 1191 ); 1192 } 1193 } 1194 1195 /** 1196 * Get the skin import form. 1197 * 1198 * @return HTML The form or a message if no new skin directory is found. 1199 */ 1200 1201 protected function getImportForm() 1202 { 1203 $event = $this->getEvent(); 1204 $dirPath = $this->getDirPath(); 1205 1206 if (is_dir($dirPath) && is_writable($dirPath)) { 1207 $new = array_diff_key($this->getUploaded(false), $this->getInstalled()); 1208 1209 if ($new) { 1210 asort($new); 1211 1212 return n 1213 .tag_start('form', array( 1214 'id' => $event.'_import_form', 1215 'name' => $event.'_import_form', 1216 'method' => 'post', 1217 'action' => 'index.php', 1218 )) 1219 .tag(gTxt('import_from_disk'), 'label', array('for' => $event.'_import')) 1220 .popHelp($event.'_import') 1221 .selectInput('skins', $new, '', false, false, 'skins') 1222 .eInput($this->getEvent()) 1223 .sInput('import') 1224 .fInput('submit', '', gTxt('import')) 1225 .n 1226 .tag_end('form'); 1227 } 1228 } else { 1229 return n 1230 .graf( 1231 span(null, array('class' => 'ui-icon ui-icon-alert')).' '. 1232 gTxt('path_not_writable', array('list' => $this->getDirPath())), 1233 array('class' => 'alert-block warning') 1234 ); 1235 } 1236 } 1237 1238 /** 1239 * Get the class related Admin\Paginator instance. 1240 * 1241 * @return object Admin\Paginator instance. 1242 */ 1243 1244 protected function getPaginator() 1245 { 1246 return \Txp::get('\Textpattern\Admin\Paginator', $this->getEvent(), ''); 1247 } 1248 1249 /** 1250 * Get the class related Search\Filter instance. 1251 * 1252 * @param array $methods Available search methods. 1253 * @return object Search\Filter instance. 1254 */ 1255 1256 protected function getSearchFilter($methods) 1257 { 1258 return \Txp::get('Textpattern\Search\Filter', $this->getEvent(), $methods); 1259 } 1260 1261 /** 1262 * Get the button to create a new skin. 1263 * 1264 * @return HTML Link. 1265 */ 1266 1267 protected function getCreateButton() 1268 { 1269 $event = $this->getEvent(); 1270 1271 return sLink($event, 'edit', gTxt('create_skin'), 'txp-button'); 1272 } 1273 1274 /** 1275 * Get the Admin\Table $content block. 1276 * 1277 * @param array $data compact('offset', 'limit', 'total', 'criteria', 'crit', 'search_method', 'page', 'sort', 'dir') 1278 * @return HTML Skin list. 1279 */ 1280 1281 protected function getContentBlock($data) 1282 { 1283 extract($data); 1284 1285 $event = $this->getEvent(); 1286 $sortSQL = $sort.' '.$dir; 1287 $switchDir = ($dir == 'desc') ? 'asc' : 'desc'; 1288 1289 if ($total < 1) { 1290 if ($crit !== '') { 1291 $out = graf( 1292 span(null, array('class' => 'ui-icon ui-icon-info')).' '. 1293 gTxt('no_results_found'), 1294 array('class' => 'alert-block information') 1295 ); 1296 } else { 1297 $out = graf( 1298 span(null, array('class' => 'ui-icon ui-icon-info')).' '. 1299 gTxt('no_'.$event.'_recorded'), 1300 array('class' => 'alert-block information') 1301 ); 1302 } 1303 1304 return $out 1305 .n.tag_end('div') // End of .txp-layout-1col. 1306 .n.'</div>'; // End of .txp-layout. 1307 } 1308 1309 $rs = $this->getTableData($criteria, $sortSQL, $offset, $limit); 1310 $numThemes = mysqli_num_rows($rs); 1311 1312 if ($rs) { 1313 $dev_preview = has_privs('skin.edit'); 1314 $out = n.tag_start('form', array( 1315 'class' => 'multi_edit_form', 1316 'id' => $event.'_form', 1317 'name' => 'longform', 1318 'method' => 'post', 1319 'action' => 'index.php', 1320 )). 1321 n.tag_start('div', array( 1322 'class' => 'txp-listtables', 1323 'tabindex' => 0, 1324 'aria-label' => gTxt('list'), 1325 )). 1326 n.tag_start('table', array('class' => 'txp-list')). 1327 n.tag_start('thead'); 1328 1329 $ths = hCell( 1330 fInput('checkbox', 'select_all', 0, '', '', '', '', '', 'select_all'), 1331 '', 1332 ' class="txp-list-col-multi-edit" scope="col" title="'.gTxt('toggle_all_selected').'"' 1333 ); 1334 1335 $thIds = array( 1336 'name' => 'name', 1337 'title' => 'title', 1338 'version' => 'version', 1339 'author' => 'author', 1340 'section_count' => 'tab_sections', 1341 'page_count' => 'tab_pages', 1342 'form_count' => 'tab_forms', 1343 'css_count' => 'tab_style', 1344 ); 1345 1346 foreach ($thIds as $thId => $thVal) { 1347 $thClass = 'txp-list-col-'.$thId 1348 .($thId == $sort ? ' '.$dir : '') 1349 .($thVal !== $thId ? ' '.$event.'_detail' : ''); 1350 1351 $ths .= column_head($thVal, $thId, $event, true, $switchDir, $crit, $search_method, $thClass); 1352 } 1353 1354 $out .= tr($ths) 1355 .n.tag_end('thead') 1356 .n.tag_start('tbody'); 1357 1358 while ($a = nextRow($rs)) { 1359 extract($a, EXTR_PREFIX_ALL, $event); 1360 1361 $editUrl = array( 1362 'event' => $event, 1363 'step' => 'edit', 1364 'name' => $skin_name, 1365 'sort' => $sort, 1366 'dir' => $dir, 1367 'page' => $page, 1368 'search_method' => $search_method, 1369 'crit' => $crit, 1370 ); 1371 1372 $tdAuthor = txpspecialchars($skin_author); 1373 empty($skin_author_uri) or $tdAuthor = href($tdAuthor, $skin_author_uri, array('rel' => 'external')); 1374 1375 $tds = td(fInput('checkbox', 'selected[]', $skin_name), '', 'txp-list-col-multi-edit') 1376 .hCell( 1377 href(txpspecialchars($skin_name), $editUrl, array('title' => gTxt('edit'))). 1378 ($numThemes > 1 ? ' | '.href(gTxt('assign_sections'), 'index.php?event=section&step=section_select_skin&skin='.urlencode($skin_name)). 1379 (${$event.'_section_count'} > 0 ? sp.tag(gTxt('status_in_use'), 'small', array('class' => 'alert-block alert-pill success')) : 1380 (${$event.'_dev_section_count'} > 0 ? sp.tag(gTxt('status_in_use'), 'small', array('class' => 'alert-block alert-pill warning')) : '') 1381 ) : ''), 1382 '', array( 1383 'scope' => 'row', 1384 'class' => 'txp-list-col-name', 1385 ) 1386 ) 1387 .td(txpspecialchars($skin_title), '', 'txp-list-col-title') 1388 .td(txpspecialchars($skin_version), '', 'txp-list-col-version') 1389 .td($tdAuthor, '', 'txp-list-col-author'); 1390 1391 $countNames = array('section', 'page', 'form', 'css'); 1392 1393 foreach ($countNames as $name) { 1394 if (${$event.'_'.$name.'_count'} > 0) { 1395 if ($name === 'section') { 1396 $linkParams = array( 1397 'event' => 'section', 1398 'search_method' => $event, 1399 'crit' => '"'.$skin_name.'"', 1400 ); 1401 } else { 1402 $linkParams = array( 1403 'event' => $name, 1404 $event => $skin_name, 1405 ); 1406 } 1407 1408 $tdVal = href( 1409 ${$event.'_'.$name.'_count'}, 1410 $linkParams, 1411 array( 1412 'title' => gTxt( 1413 $event.'_count_'.$name, 1414 array('{num}' => ${$event.'_'.$name.'_count'}) 1415 ) 1416 ) 1417 ); 1418 } else { 1419 $tdVal = 0; 1420 } 1421 1422 $tds .= td($tdVal, '', 'txp-list-col-'.$name.'_count'); 1423 } 1424 1425 $out .= tr($tds, array('id' => $this->getTable().'_'.$skin_name)); 1426 } 1427 1428 return $out 1429 .n.tag_end('tbody') 1430 .n.tag_end('table') 1431 .n.tag_end('div') 1432 .n.self::getMultiEditForm($page, $sort, $dir, $crit, $search_method) 1433 .tInput() 1434 .n.tag_end('form'); 1435 } 1436 } 1437 1438 /** 1439 * Get the Admin\Table $foot block. 1440 * 1441 * @param array $data compact('limit', 'numPages', 'total', 'crit', 'search_method', 'page', 'sort', 'dir') 1442 * @return HTML Multi-edit form, pagination and navigation form. 1443 */ 1444 1445 protected function getFootBlock($data) 1446 { 1447 extract($data); 1448 1449 return $this->getPaginator()->render() 1450 .nav_form($this->getEvent(), $page, $numPages, $sort, $dir, $crit, $search_method, $total, $limit); 1451 } 1452 1453 /** 1454 * Get a multi-edit checkbox. 1455 * 1456 * @param string $label The textpack related string to use. 1457 * @return HTML 1458 */ 1459 1460 protected function getMultiEditCheckbox($label) 1461 { 1462 return checkbox2('sync', get_pref($label, true), 0, 'sync') 1463 .n.tag(gTxt($label), 'label', array('for' => 'sync')) 1464 .popHelp($label); 1465 } 1466 1467 /** 1468 * Render a multi-edit form widget. 1469 * 1470 * @param int $page The current page number 1471 * @param string $sort The current sorting value 1472 * @param string $dir The current sorting direction 1473 * @param string $crit The current search criteria 1474 * @param string $search_method The current search method 1475 * @return HTML 1476 */ 1477 1478 protected function getMultiEditForm($page, $sort, $dir, $crit, $search_method) 1479 { 1480 $methods = array( 1481 'import' => array( 1482 'label' => gTxt('update_from_disk'), 1483 'html' => $this->getMultiEditCheckbox('skin_delete_from_database'), 1484 ), 1485 'export' => array( 1486 'label' => gTxt('export_to_disk'), 1487 'html' => $this->getMultiEditCheckbox('skin_delete_from_disk'), 1488 ), 1489 'duplicate' => gTxt('duplicate'), 1490 'delete' => array( 1491 'label' => gTxt('delete'), 1492 'html' => $this->getMultiEditCheckbox('skin_delete_entirely'), 1493 ), 1494 ); 1495 1496 return multi_edit($methods, $this->getEvent(), 'multi_edit', $page, $sort, $dir, $crit, $search_method); 1497 } 1498 1499 /** 1500 * Get the edit form. 1501 * 1502 * @param mixed $message 1503 * @return HTML 1504 */ 1505 1506 protected function getEditForm($message = '') 1507 { 1508 global $step; 1509 1510 $event = $this->getEvent(); 1511 1512 require_privs($event.'.edit'); 1513 1514 !$message or pagetop(gTxt('tab_'.$event), $message); 1515 1516 extract(gpsa(array( 1517 'page', 1518 'sort', 1519 'dir', 1520 'crit', 1521 'search_method', 1522 'name', 1523 ))); 1524 1525 $fields = array('name', 'title', 'version', 'description', 'author', 'author_uri'); 1526 1527 if ($name) { 1528 $rs = $this->setName($name)->getRow(); 1529 1530 if (!$rs) { 1531 return $this->main(); 1532 } 1533 1534 $caption = gTxt('edit_'.$event); 1535 $extraAction = href( 1536 '<span class="ui-icon ui-icon-copy"></span> '.gTxt('duplicate'), 1537 '#', 1538 array( 1539 'class' => 'txp-clone', 1540 'data-form' => $event.'_form', 1541 ) 1542 ); 1543 } else { 1544 $rs = array_fill_keys($fields, ''); 1545 $caption = gTxt('create_'.$event); 1546 $extraAction = ''; 1547 } 1548 1549 extract($rs, EXTR_PREFIX_ALL, $event); 1550 pagetop(gTxt('tab_'.$event)); 1551 1552 $content = hed($caption, 2); 1553 1554 foreach ($fields as $field) { 1555 $current = ${$event.'_'.$field}; 1556 1557 if ($field === 'description') { 1558 $input = text_area($field, 0, 0, $current, $event.'_'.$field); 1559 } elseif ($field === 'name') { 1560 $input = fInput( 1561 'text', 1562 array( 1563 'name' => $field, 1564 'maxlength' => '63', 1565 ), $current, '', '', '', INPUT_REGULAR, '', $event.'_'.$field, '', true 1566 ); 1567 } elseif ($field === 'author_uri') { 1568 $input = fInput('url', $field, $current, '', '', '', INPUT_REGULAR, '', $event.'_'.$field, '', '', 'http(s)://'); 1569 } else { 1570 $input = fInput('text', $field, $current, '', '', '', INPUT_REGULAR, '', $event.'_'.$field); 1571 } 1572 1573 $content .= inputLabel($event.'_'.$field, $input, $event.'_'.$field, $event.'_'.$field); 1574 } 1575 1576 $content .= pluggable_ui($event.'_ui', 'extend_detail_form', '', $rs) 1577 .graf( 1578 $extraAction. 1579 sLink($event, '', gTxt('cancel'), 'txp-button') 1580 .fInput('submit', '', gTxt('save'), 'publish'), 1581 array('class' => 'txp-edit-actions') 1582 ) 1583 .eInput($event) 1584 .sInput('save') 1585 .hInput('old_name', $skin_name) 1586 .hInput('old_title', $skin_title) 1587 .hInput('search_method', $search_method) 1588 .hInput('crit', $crit) 1589 .hInput('page', $page) 1590 .hInput('sort', $sort) 1591 .hInput('dir', $dir); 1592 1593 return form($content, '', '', 'post', 'txp-edit', '', $event.'_form'); 1594 } 1595 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body
title