diff --git a/wire/core/PagesExportImport.php b/wire/core/PagesExportImport.php index 0dd650c7..a1920e76 100644 --- a/wire/core/PagesExportImport.php +++ b/wire/core/PagesExportImport.php @@ -2,6 +2,24 @@ /** * ProcessWire Pages Export/Import Helpers + * + * This class is in development and not yet ready for use. + * + * $options argument for import methods: + * + * - `parent` (Page|int|string): Parent Page, path or ID. (default=0, auto detect from imported page path) + * - `template` (Template|int|string): Template object, name or ID. (default=0, auto detect from imported page template) + * - `update` (bool): Update existing Page (rather than create new) if another page already has the same name+parent? (default=true) + * - `skip` (bool): Skip page update/create if page already exists? (default=false) + * - `changeTemplate` (bool): Allow template to be changed on updated pages? (default=false) + * - `changeParent` (bool): Allow parent of existing pages to be changed? (default=false) + * - `changeName` (bool): Allow name of existing pages to be changed? (default=false) + * - `changeStatus` (bool): Allow status of existing pages to be changed? (default=true) + * - `changeSort` (bool): Allow sort and sortfield properties of existing pages to be changed? (default=true) + * - `saveOptions` (array): The $options agument provided to Pages::save() method. (default=['adjustName'=>true]) + * + * Note: all the "change" prefix options require update=true and skip=false options to be set. + * * * ProcessWire 3.x, Copyright 2017 by Ryan Cramer * https://processwire.com @@ -9,6 +27,62 @@ */ class PagesExportImport extends Wire { + + /** + * Export given PageArray to a ZIP file (method not yet implemented) + * + * @param PageArray $items + * @param array $options + * @return string|bool Path+filename to ZIP file or boolean false on failure + * + */ + public function exportZIP(PageArray $items, array $options = array()) { + return false; + } + + /** + * Import ZIP file to create pages (method not yet implemented) + * + * @param string $filename Path+filename to ZIP file + * @param array $options + * @return PageArray|int Pages that were imported (or count, if requested) + * + */ + public function importZIP($filename, array $options = array()) { + $pageArray = $this->wire('pages')->newPageArray(); + return $options['count'] ? $pageArray->count() : $pageArray; + } + + /** + * Export a PageArray to JSON string + * + * @param PageArray $items + * @param array $options + * @return string|bool JSON string of pages or boolean false on error + * + */ + public function exportJSON(PageArray $items, array $options = array()) { + $data = $this->pagesToArray($items, $options); + $data = json_encode($data); + return $data; + } + + /** + * Import a PageArray from a JSON string + * + * Given JSON string must be one previously exported by the exportJSON() method in this class. + * + * @param string $json + * @param array $options + * @return PageArray|bool + * + */ + public function importJSON($json, array $options = array()) { + $data = json_decode($json, true); + if($data === false) return false; + $pageArray = $this->arrayToPages($data, $options); + return $pageArray; + } /** * Given a PageArray export it to a portable PHP array @@ -18,7 +92,7 @@ class PagesExportImport extends Wire { * @return array * */ - function pagesToArray(PageArray $items, array $options = array()) { + public function pagesToArray(PageArray $items, array $options = array()) { $defaults = array( 'verbose' => false, @@ -113,7 +187,7 @@ class PagesExportImport extends Wire { * */ protected function pageToArray(Page $page, array $options) { - + $of = $page->of(); $page->of(false); @@ -131,15 +205,17 @@ class PagesExportImport extends Wire { ); // verbose page settings - if(!empty($options['verbose'])) $settings = array_merge($settings, array( - 'parent_id' => $page->parent_id, - 'templates_id' => $page->templates_id, - 'created' => $page->createdStr, - 'modified' => $page->modifiedStr, - 'published' => $page->publishedStr, - 'created_user' => $page->createdUser->name, - 'modified_user' => $page->modifiedUser->name, - )); + if(!empty($options['verbose'])) { + $settings = array_merge($settings, array( + 'parent_id' => $page->parent_id, + 'templates_id' => $page->templates_id, + 'created' => $page->createdStr, + 'modified' => $page->modifiedStr, + 'published' => $page->publishedStr, + 'created_user' => $page->createdUser->name, + 'modified_user' => $page->modifiedUser->name, + )); + } // include multi-language page names and statuses when applicable if($languages && $this->wire('modules')->isInstalled('LanguageSupportPageNames')) { @@ -152,7 +228,9 @@ class PagesExportImport extends Wire { // array of export data $a = array( + 'type' => 'ProcessWire:Page', 'path' => $page->path(), + 'class' => $page->className(true), 'template' => $page->template->name, 'settings' => $settings, 'data' => array(), @@ -161,29 +239,305 @@ class PagesExportImport extends Wire { // iterate all fields and export value from each foreach($page->template->fieldgroup as $field) { - - /** @var Field $field */ - /** @var Fieldtype $fieldtype */ - $fieldtype = $field->type; - $schema = $fieldtype->getDatabaseSchema($field); - - if(!isset($schema['xtra']['all']) || $schema['xtra']['all'] !== true) { - // this fieldtype is storing data outside of the DB or in other unknown tables - // there's a good chance we won't be able to export/import this into an array - // @todo check if fieldtype implements its own exportValue/importValue, and if - // it does then allow the value to be exported - $a['warnings'][$field->name] = "Skipped '$field' because $field->type uses data outside table '$field->table'"; + + $info = $this->getFieldInfo($field); + if(!$info['exportable']) { + $a['warnings'][$field->name] = $info['reason']; continue; } + /** @var Field $field */ + /** @var Fieldtype $fieldtype */ $value = $page->get($field->name); - $exportValue = $fieldtype->exportValue($page, $field, $value, array('system' => true)); + $exportValue = $field->type->exportValue($page, $field, $value, array('system' => true)); $a['data'][$field->name] = $exportValue; } if($of) $page->of(true); + if($languages) $languages->unsetDefault(); return $a; } + /** + * Import an array of page data to create or update pages + * + * Provided array ($a) must originate from the pagesToArray() method format. + * + * @param array $a + * @param array $options + * @return PageArray|bool + * @throws WireException + * + */ + public function arrayToPages(array $a, array $options = array()) { + + if(empty($a['type']) || $a['type'] != 'ProcessWire:PageArray') { + throw new WireException("Invalid array provided to arrayToPages() method"); + } + + $defaults = array( + 'count' => false, // Return count of imported pages, rather than PageArray (reduced memory requirements) + ); + + $options = array_merge($defaults, $options); + $pageArray = $this->wire('pages')->newPageArray(); + $count = 0; + + // $a has: type (string), version (string), pagination (array), pages (array), fields (array) + + if(empty($a['pages'])) return $options['count'] ? 0 : $pageArray; + + foreach($a['pages'] as $item) { + $page = $this->arrayToPage($item, $options); + $e = $page->errors('string clear'); + $w = $page->warnings('string clear'); + $id = $item['settings']['id']; + if(strlen($e)) foreach(explode("\n", $e) as $s) $pageArray->error("Page $id: $s"); + if(strlen($w)) foreach(explode("\n", $w) as $s) $pageArray->warning("Page $id: $s"); + $count++; + if(!$options['count']) $pageArray->add($page); + } + + return $options['count'] ? $count : $pageArray; + } + + /** + * Import an array of page data to a new Page (or update existing page) + * + * Provided array ($a) must originate from the pageToArray() method format. + * + * @param array $a + * @param array $options + * @return Page|NullPage + * @throws WireException + * + */ + public function arrayToPage(array $a, array $options = array()) { + + if(empty($a['type']) || $a['type'] != 'ProcessWire:Page') { + throw new WireException('Invalid array provided to arrayToPage() method'); + } + + $defaults = array( + 'id' => 0, // ID that new Page should use, or update, if it already exists. (0=create new). Sets updatePage=true. + 'parent' => 0, // Parent Page, path or ID. (0=auto detect from imported page path) + 'template' => '', // Template object, name or ID. (0=auto detect from imported page template) + 'update' => true, // update existing Page (rather than create new) if another page already has the same name+parent? + 'skip' => false, // skip page update/create if page already exists? + 'changeTemplate' => false, // allow template to be changed on updated pages? (requires update=true, skip=false) + 'changeParent' => false, + 'changeName' => true, + 'changeStatus' => true, + 'changeSort' => true, + 'saveOptions' => array('adjustName' => true), // options passed to Pages::save + ); + + $options = array_merge($defaults, $options); + $errors = array(); // fatal errors + $warnings = array(); // non-fatal warnings + $pages = $this->wire('pages'); + $path = $a['path']; + $languages = $this->wire('languages'); + $fileFields = array(); + + if($options['id']) { + $options['update'] = true; + $options['skip'] = false; + } + + /** @var Languages $languages */ + if($languages) $languages->setDefault(); + + // determine parent + if($options['parent']) { + // parent specified in options + if(is_object($options['parent']) && $options['parent'] instanceof Page) { + $parent = $options['parent']; + } else if(ctype_digit("$options[parent]")) { + $parent = $pages->get((int) $options['parent']); + } else { + $parent = $pages->get('/' . ltrim($options['parent'], '/')); + } + if(!$parent->id) $errors[] = "Specified parent does not exist: $options[parent]"; + } else if(strrpos($path, '/')) { + // determine parent from imported page path + $parts = explode('/', trim($path, '/')); + array_pop($parts); // pop off name + $parentPath = '/' . implode('/', $parts); + $parent = $pages->get($parentPath); + if(!$parent->id) $errors[] = "Unable to locate parent page: $parentPath"; + } else if($path === '/') { + // homepage, parent is not applicable + $parent = new NullPage(); + } else { + // parent cannot be determined + $parent = new NullPage(); + $errors[] = "Unable to determine parent"; + } + + // determine template + $template = empty($options['template']) ? $a['template'] : $options['template']; + if(!is_object($template)) { + $_template = $template; + $template = $this->wire('templates')->get($template); + if(!$template) $errors[] = "Unable to locate template: $_template"; + } + + // determine page (new or existing) + /** @var Page|NullPage $page */ + if(!empty($options['id'])) { + $page = $pages->get((int) $options['id']); + if(!$page->id) { + $errors[] = "Unable to find specified page to update by ID: $options[id]"; + } + } else { + $page = $pages->get($path); + if($page->id) { + // updating existing Page + } else if(wireClassExists($a['class'])) { + // use specified class + $page = new $a['class'](); + } else { + // requested page class does not exist (warning?) + $page = new Page(); + } + } + + if($page->id) { + // page laready exists, determine if we should update it + if($options['skip']) { + $errors[] = "Skipped update to page because options[skip=true]: $page->path"; + } else if($options['update']) { + // existing page will be updated + } else { + // create new page rather than updating existing page + $page = new Page(); + } + } + + // if any errors occurred above, abort + if(count($errors) || $page instanceof NullPage) { + foreach($errors as $error) $page->error($error); + return $page; + } + + // populate page base settings + $isNew = $page->id == 0; + $page->of(false); + if($options['changeTemplate'] || $isNew) $page->template = $template; + if($options['changeParent'] || $isNew) $page->parent = $parent; + if($options['changeName'] || $isNew) $page->name = $a['settings']['name']; + if($options['changeStatus'] || $isNew) $page->status = $a['settings']['status']; + if($options['changeSort'] || $isNew) { + $page->sort = $a['settings']['sort']; + $page->sortfield = $a['settings']['sortfield']; + } + + // save blank page now if it is new, so that it has an ID + if($isNew) $pages->save($page, $options['saveOptions']); + + // populate custom fields + foreach($page->template->fieldgroup as $field) { + if(!isset($a['data'][$field->name])) { + $warnings[] = "Skipped field “$field->name” - template “$template” does not have it"; + continue; + } else if($field->type instanceof FieldtypeFile) { + $fileFields[] = $field; + continue; + } + try { + $value = $field->type->importValue($page, $field, $a['data'][$field->name], array('system' => true)); + $page->set($field->name, $value); + } catch(\Exception $e) { + $warnings[] = $e->getMessage(); + } + } + + // handle file fields + if(count($fileFields)) { + foreach($fileFields as $field) { + $this->importFileField($page, $field, $a['data'][$field->name], $options); + } + } + + $pages->save($page, $options['saveOptions']); + + if($languages) $languages->unsetDefault(); + + foreach($errors as $error) $page->error($error); + foreach($warnings as $warning) $page->warning($warning); + + return $page; + } + + /** + * Import a files/images field and populate to given $page + * + * @param Page $page + * @param Field $field + * @param array $data Export/sleep value of file field + * @param array $options + * + */ + protected function importFileField(Page $page, Field $field, array $data, array $options = array()) { + + // Expected format of given $data argument: + // $data = [ + // 'file1.jpg' => [ + // 'url' => 'http://domain.com/site/assets/files/123/file1.jpg', + // 'description' => 'file description', + // 'tags' => 'file tags' + // ], + // 'file2.png' => [ ... see above ... ], + // 'file3.gif' => [ ... see above ... ], + // ]; + + // @todo method needs implementation + + } + + /** + * Returns array of information about given Field + * + * Populates the following indexes: + * - `exportable` (bool): True if field is exportable, false if not. + * - `reason` (string): Reason why field is not exportable (when exportable==true). + * + * @param Field $field + * @return array + * + */ + protected function getFieldInfo(Field $field) { + + $info = array( + 'exportable' => true, + 'reason' => '', + ); + + if($field->type instanceof FieldtypeFile) { + // we will handle these + return $info; + } + + try { + $schema = $field->type->getDatabaseSchema($field); + } catch(\Exception $e) { + $info['exportable'] = false; + $info['reason'] = $e->getMessage(); + return $info; + } + + if(!isset($schema['xtra']['all']) || $schema['xtra']['all'] !== true) { + // this fieldtype is storing data outside of the DB or in other unknown tables + // there's a good chance we won't be able to export/import this into an array + // @todo check if fieldtype implements its own exportValue/importValue, and if + // it does then allow the value to be exported + $info['exportable'] = false; + $info['reason'] = "Field '$field' cannot be exported because $field->type uses data outside table '$field->table'"; + } + + return $info; + } + }