pages = $pages; $config = $pages->wire()->config; if($config->dbStripMB4 && strtolower($config->dbEngine) != 'utf8mb4') { $this->addHookAfter('Fieldtype::sleepValue', $this, 'hookFieldtypeSleepValueStripMB4'); } } /** * Are we currently in a page clone? * * @param bool $getDepth Get depth (int) rather than state (bool)? * @return bool|int * */ public function isCloning($getDepth = false) { return $getDepth ? $this->cloning : $this->cloning > 0; } /** * Add a new page using the given template to the given parent * * If no name is specified one will be assigned based on the current timestamp. * * @param string|Template $template Template name or Template object * @param string|int|Page $parent Parent path, ID or Page object * @param string $name Optional name or title of page. If none provided, one will be automatically assigned based on microtime stamp. * If you want to specify a different name and title then specify the $name argument, and $values['title']. * @param array $values Field values to assign to page (optional). If $name is omitted, this may also be 3rd param. * @return Page Returned page has output formatting off. * @throws WireException When some criteria prevents the page from being saved. * */ public function add($template, $parent, $name = '', array $values = array()) { // the $values may optionally be the 3rd argument if(is_array($name)) { $values = $name; $name = isset($values['name']) ? $values['name'] : ''; } if(!is_object($template)) { $template = $this->wire()->templates->get($template); if(!$template) throw new WireException("Unknown template"); } $options = array('template' => $template, 'parent' => $parent); if(isset($values['pageClass'])) { $options['pageClass'] = $values['pageClass']; unset($values['pageClass']); } $page = $this->pages->newPage($options); $exceptionMessage = "Unable to add new page using template '$template' and parent '{$page->parent->path}'."; if(empty($values['title'])) { // no title provided in $values, so we assume $name is $title // but if no name is provided, then we default to: Untitled Page if(!strlen($name)) $name = $this->_('Untitled Page'); // the setupNew method will convert $page->title to a unique $page->name $page->title = $name; } else { // title was provided $page->title = $values['title']; // if name is provided we use it // otherwise setupNew will take care of assign it from title if(strlen($name)) $page->name = $name; unset($values['title']); } if(isset($values['status'])) { $page->status = $values['status']; unset($values['status']); } // save page before setting $values just in case any fieldtypes // require the page to have an ID already (like file-based) if(!$this->pages->save($page)) throw new WireException($exceptionMessage); // set field values, if provided if(!empty($values)) { unset($values['id'], $values['parent'], $values['template']); // fields that may not be set from this array foreach($values as $key => $value) $page->set($key, $value); $this->pages->save($page); } // get a fresh copy of the page if($page->id) { $inserted = $page->_inserted; $of = $this->pages->outputFormatting; if($of) $this->pages->setOutputFormatting(false); $p = $this->pages->getById($page->id, $template, $page->parent_id); if($p->id) $page = $p; if($of) $this->pages->setOutputFormatting(true); $page->setQuietly('_inserted', $inserted); } return $page; } /** * Is the given page in a state where it can be saved from the API? * * @param Page $page * @param string $reason Text containing the reason why it can't be saved (assuming it's not saveable) * @param string|Field $fieldName Optional fieldname to limit check to. * @param array $options Options array given to the original save method (optional) * @return bool True if saveable, False if not * */ public function isSaveable(Page $page, &$reason, $fieldName = '', array $options = array()) { $saveable = false; $outputFormattingReason = "Call \$page->of(false); before getting/setting values that will be modified and saved."; $corrupted = array(); if($fieldName && is_object($fieldName)) { /** @var Field $fieldName */ $fieldName = $fieldName->name; /** @var string $fieldName */ } if($page->hasStatus(Page::statusCorrupted)) { $corruptedFields = $page->_statusCorruptedFields; foreach($page->getChanges() as $change) { if(isset($corruptedFields[$change])) $corrupted[] = $change; } // if focused on a specific field... if($fieldName && !in_array($fieldName, $corrupted)) $corrupted = array(); } if($page instanceof NullPage) { $reason = "Pages of type NullPage are not saveable"; } else if(!$page->parent_id && $page->id !== 1 && (!$page->parent || $page->parent instanceof NullPage)) { $reason = "It has no parent assigned"; } else if(!$page->template) { $reason = "It has no template assigned"; } else if(!strlen(trim($page->name)) && $page->id != 1) { $reason = "It has an empty 'name' field"; } else if(count($corrupted)) { $reason = $outputFormattingReason . " [Page::statusCorrupted] fields: " . implode(', ', $corrupted); } else if($page->id == 1 && !$page->template->useRoles) { $reason = "Selected homepage template cannot be used because it does not define access."; } else if($page->id == 1 && !$page->template->hasRole('guest')) { $reason = "Selected homepage template cannot be used because it does not have required 'guest' role in its access settings."; } else { $saveable = true; } // check if they could corrupt a field by saving if($saveable && $page->outputFormatting) { // iternate through recorded changes to see if any custom fields involved foreach($page->getChanges() as $change) { if($fieldName && $change != $fieldName) continue; if($page->template->fieldgroup->getField($change) !== null) { $reason = $outputFormattingReason . " [$change]"; $saveable = false; break; } } // iterate through already-loaded data to see if any are objects that have changed if($saveable) foreach($page->getArray() as $key => $value) { if($fieldName && $key != $fieldName) continue; if(!$page->template->fieldgroup->getField($key)) continue; if(is_object($value) && $value instanceof Wire && $value->isChanged()) { $reason = $outputFormattingReason . " [$key]"; $saveable = false; break; } } } // check for a parent change and whether it is allowed if($saveable && $page->id && $page->parentPrevious && empty($options['ignoreFamily'])) { // parent has changed, check that the move is allowed $saveable = $this->isMoveable($page, $page->parentPrevious, $page->parent, $reason); } return $saveable; } /** * Return whether given Page is moveable from $oldParent to $newParent * * @param Page $page Page to move * @param Page $oldParent Current/old parent page * @param Page $newParent New requested parent page * @param string $reason Populated with reason why page is not moveable, if return false is false. * @return bool * */ public function isMoveable(Page $page, Page $oldParent, Page $newParent, &$reason) { if($oldParent->id == $newParent->id) return true; $config = $this->wire()->config; $moveable = false; $isSystem = $page->hasStatus(Page::statusSystem) || $page->hasStatus(Page::statusSystemID); $toTrash = $newParent->id > 0 && $newParent->isTrash(); $wasTrash = $oldParent->id > 0 && $oldParent->isTrash(); // page was moved if($page->template->noMove && ($isSystem || (!$toTrash && !$wasTrash))) { // make sure the page template allows moves. // only move always allowed is to the trash (or out of it), unless page has system status $reason = sprintf($this->_('Page using template “%s” is not moveable.'), $page->template->name) . ' ' . "(Template::noMove) [{$oldParent->path} => {$newParent->path}]"; } else if($newParent->template->noChildren) { // check if new parent disallows children $reason = sprintf( $this->_('Chosen parent “%1$s” uses template “%2$s” that does not allow children.'), $newParent->path, $newParent->template->name ); } else if($newParent->id && $newParent->id != $config->trashPageID && count($newParent->template->childTemplates) && !in_array($page->template->id, $newParent->template->childTemplates)) { // make sure the new parent's template allows pages with this template $reason = sprintf( $this->_('Cannot move “%1$s” because template “%2$s” used by page “%3$s” does not allow children using template “%4$s”.'), $page->name, $newParent->template->name, $newParent->path, $page->template->name ); } else if(count($page->template->parentTemplates) && $newParent->id != $config->trashPageID && !in_array($newParent->template->id, $page->template->parentTemplates)) { // check for allowed parentTemplates setting $reason = sprintf( $this->_('Cannot move “%1$s” because template “%2$s” used by new parent “%3$s” is not allowed by moved page template “%4$s”.'), $page->name, $newParent->template->name, $newParent->path, $page->template->name ); } else if(count($newParent->children("name=$page->name, id!=$page->id, include=all"))) { // check for page name collision $reason = sprintf( $this->_('Chosen parent “%1$s” already has a page named “%2$s”.'), $newParent->path, $page->name ); } else { $moveable = true; } return $moveable; } /** * Is the given page deleteable from the API? * * Note: this does not account for user permission checking. It only checks if the page is in a state to be saveable via the API. * * @param Page $page * @param bool $throw Throw WireException with additional details? * @return bool True if deleteable, False if not * @throws WireException If requested to do so via $throw argument * */ public function isDeleteable(Page $page, $throw = false) { $error = false; if($page instanceof NullPage) { $error = "it is a NullPage"; } else if(!$page->id) { $error = "it has no id"; } else if($page->hasStatus(Page::statusSystemID) || $page->hasStatus(Page::statusSystem)) { $error = "it has “system” and/or “systemID” status"; } else if($page->hasStatus(Page::statusLocked)) { $error = "it has “locked” status"; } else if($page->id === $this->wire()->page->id && $this->wire()->config->installedAfter('2019-04-04')) { $error = "it is the current page being viewed, try \$pages->trash() instead"; } if($error === false) return true; if($throw) throw new WireException("Page $page->path ($page->id) cannot be deleted: $error"); return false; } /** * Auto-populate some fields for a new page that does not yet exist * * Currently it does this: * * - Assigns a parent if one is not already assigned. * - Sets up a unique page->name based on the format or title if one isn't provided already. * - Assigns a sort value. * - Populates any default values for fields. * * @param Page $page * @throws \Exception|WireException|\PDOException if failure occurs while in DB transaction * */ public function setupNew(Page $page) { $parent = $page->parent(); // assign parent if(!$parent->id) { $parentTemplates = $page->template->parentTemplates; $parent = null; if(!empty($parentTemplates)) { $idStr = implode('|', $parentTemplates); $parent = $this->pages->get("include=hidden, template=$idStr"); if(!$parent->id) $parent = $this->pages->get("include=all, template=$idStr"); } if($parent->id) $page->parent = $parent; } // assign page name if(!strlen($page->name)) { $this->pages->setupPageName($page); // call through $pages intended, so it can be hooked } // assign sort order if($page->sort < 0) { $page->sort = $page->parent->numChildren(); } // assign any default values for fields foreach($page->template->fieldgroup as $field) { if($page->isLoaded($field->name)) continue; // value already set if(!$page->hasField($field)) continue; // field not valid for page if(!strlen("$field->defaultValue")) continue; // no defaultValue property defined with Fieldtype config inputfields try { $blankValue = $field->type->getBlankValue($page, $field); if(is_object($blankValue) || is_array($blankValue)) continue; // we don't currently handle complex types $defaultValue = $field->type->getDefaultValue($page, $field); if(is_object($defaultValue) || is_array($defaultValue)) continue; // we don't currently handle complex types if("$blankValue" !== "$defaultValue") { $page->set($field->name, $defaultValue); } } catch(\Exception $e) { $this->trackException($e, false, true); if($this->wire()->database->inTransaction()) throw $e; } } } /** * Auto-assign a page name to this page * * Typically this would be used only if page had no name or if it had a temporary untitled name. * * Page will be populated with the name given. This method will not populate names to pages that * already have a name, unless the name is "untitled" * * @param Page $page * @param array $options * - format: Optionally specify the format to use, or leave blank to auto-determine. * @return string If a name was generated it is returned. If no name was generated blank is returned. * */ public function setupPageName(Page $page, array $options = array()) { return $this->pages->names()->setupNewPageName($page, isset($options['format']) ? $options['format'] : ''); } /** * Save a page object and it's fields to database. * * If the page is new, it will be inserted. If existing, it will be updated. * * This is the same as calling $page->save() * * If you want to just save a particular field in a Page, use $page->save($fieldName) instead. * * @param Page $page * @param array $options Optional array with the following optional elements: * - `uncacheAll` (boolean): Whether the memory cache should be cleared (default=true) * - `resetTrackChanges` (boolean): Whether the page's change tracking should be reset (default=true) * - `quiet` (boolean): When true, created/modified time+user will use values from $page rather than current user+time (default=false) * - `adjustName` (boolean): Adjust page name to ensure it is unique within its parent (default=false) * - `forceID` (integer): Use this ID instead of an auto-assigned on (new page) or current ID (existing page) * - `ignoreFamily` (boolean): Bypass check of allowed family/parent settings when saving (default=false) * - `noHooks` (boolean): Prevent before/after save hooks from being called (default=false) * - `noFields` (boolean): Bypass saving of custom fields (default=false) * @return bool True on success, false on failure * @throws WireException * */ public function save(Page $page, $options = array()) { $defaultOptions = array( 'uncacheAll' => true, 'resetTrackChanges' => true, 'adjustName' => false, 'forceID' => 0, 'ignoreFamily' => false, 'noHooks' => false, 'noFields' => false, ); if(is_string($options)) $options = Selectors::keyValueStringToArray($options); $options = array_merge($defaultOptions, $options); $user = $this->wire()->user; $languages = $this->wire()->languages; $language = null; // if language support active, switch to default language so that saved fields and hooks don't need to be aware of language if($languages && $page->id != $user->id) { $language = $user->language && $user->language->id ? $user->language : null; if($language) $user->language = $languages->getDefault(); } $reason = ''; $isNew = $page->isNew(); if($isNew) $this->pages->setupNew($page); if(!$this->isSaveable($page, $reason, '', $options)) { if($language) $user->language = $language; throw new WireException("Can’t save page {$page->id}: {$page->path}: $reason"); } if($page->hasStatus(Page::statusUnpublished) && $page->template->noUnpublish) { $page->removeStatus(Page::statusUnpublished); } if($page->parentPrevious && !$isNew) { if($page->isTrash() && !$page->parentPrevious->isTrash()) { $this->pages->trash($page, false); } else if($page->parentPrevious->isTrash() && !$page->parent->isTrash()) { $this->pages->restore($page, false); } } $this->pages->names()->checkNameConflicts($page); if(!$this->savePageQuery($page, $options)) return false; $result = $this->savePageFinish($page, $isNew, $options); if($language) $user->language = $language; // restore language return $result; } /** * Execute query to save to pages table * * triggers hooks: saveReady, statusChangeReady (when status changed) * * @param Page $page * @param array $options * @return bool * @throws WireException|\Exception * */ protected function savePageQuery(Page $page, array $options) { $isNew = $page->isNew(); $database = $this->wire()->database; $sanitizer = $this->wire()->sanitizer; $config = $this->wire()->config; $user = $this->wire()->user; $userID = $user ? $user->id : $config->superUserPageID; $systemVersion = $config->systemVersion; $sql = ''; if(!$page->created_users_id) $page->created_users_id = $userID; if($page->isChanged('status') && empty($options['noHooks'])) { $this->pages->statusChangeReady($page); } if(empty($options['noHooks'])) { $extraData = $this->pages->saveReady($page); $this->pages->savePageOrFieldReady($page); } else { $extraData = array(); } if($this->pages->names()->isUntitledPageName($page->name)) { $this->pages->setupPageName($page); } $data = array( 'parent_id' => (int) $page->parent_id, 'templates_id' => (int) $page->template->id, 'name' => $sanitizer->pageName($page->name, Sanitizer::toAscii), 'status' => (int) $page->status, 'sort' => ($page->sort > -1 ? (int) $page->sort : 0) ); if(is_array($extraData)) foreach($extraData as $column => $value) { $column = $database->escapeCol($column); $data[$column] = (strtoupper($value) === 'NULL' ? NULL : $value); } if($isNew) { if($page->id) $data['id'] = (int) $page->id; $data['created_users_id'] = (int) $userID; } if($options['forceID']) $data['id'] = (int) $options['forceID']; if($page->template->allowChangeUser) { $data['created_users_id'] = (int) $page->created_users_id; } if(empty($options['quiet'])) { $sql = 'modified=NOW()'; $data['modified_users_id'] = (int) $userID; } else { // quiet option, use existing values already populated to page, when present $data['modified_users_id'] = (int) ($page->modified_users_id ? $page->modified_users_id : $userID); $data['created_users_id'] = (int) ($page->created_users_id ? $page->created_users_id : $userID); if($page->modified > 0) { $data['modified'] = date('Y-m-d H:i:s', $page->modified); } else if($isNew) { $sql = 'modified=NOW()'; } if($page->created > 0) { $data['created'] = date('Y-m-d H:i:s', $page->created); } } if(isset($data['modified_users_id'])) $page->modified_users_id = $data['modified_users_id']; if(isset($data['created_users_id'])) $page->created_users_id = $data['created_users_id']; if(!$page->isUnpublished() && ($isNew || ($page->statusPrevious && ($page->statusPrevious & Page::statusUnpublished)))) { // page is being published if($systemVersion >= 12) { $sql .= ($sql ? ', ' : '') . 'published=NOW()'; } } foreach($data as $column => $value) { $sql .= ", $column=" . (is_null($value) ? "NULL" : ":$column"); } $sql = trim($sql, ", "); if($isNew) { if(empty($data['created'])) $sql .= ', created=NOW()'; $query = $database->prepare("INSERT INTO pages SET $sql"); } else { $query = $database->prepare("UPDATE pages SET $sql WHERE id=:page_id"); $query->bindValue(":page_id", (int) $page->id, \PDO::PARAM_INT); } foreach($data as $column => $value) { if(is_null($value)) continue; // already bound above $query->bindValue(":$column", $value, is_int($value) ? \PDO::PARAM_INT : \PDO::PARAM_STR); } $tries = 0; $maxTries = 100; do { $result = false; $keepTrying = false; try { $result = $database->execute($query); } catch(\Exception $e) { $keepTrying = $this->savePageQueryException($page, $query, $e, $options); if(!$keepTrying) throw $e; } } while($keepTrying && (++$tries < $maxTries)); if($result && ($isNew || !$page->id)) { $page->id = (int) $database->lastInsertId(); $page->setQuietly('_inserted', time()); } if($options['forceID']) $page->id = (int) $options['forceID']; return $result; } /** * Handle Exception for savePageQuery() * * While setupNew() already attempts to uniqify a page name with an incrementing * number, there is a chance that two processes running at once might end up with * the same number, so we account for the possibility here by re-trying queries * that trigger duplicate-entry exceptions. * * Example of actual exception text, for reference: * Integrity constraint violation: 1062 Duplicate entry 'background-3552' for key 'name3894_parent_id' * * @param Page $page * @param \PDOStatement $query * @param \PDOException|\Exception $exception * @param array $options * @return bool True if it should give $query another shot, false if not * */ protected function savePageQueryException(Page $page, $query, $exception, array $options) { $errorCode = $exception->getCode(); // 23000=integrity constraint violation, duplicate entry if($errorCode != 23000) return false; if(!$this->pages->names()->hasAutogenName($page) && !$options['adjustName']) return false; $languages = $this->wire()->languages; $sanitizer = $this->wire()->sanitizer; // account for the duplicate possibly being a multi-language name field // i.e. “Duplicate entry 'bienvenido-2-1001' for key 'name1013_parent_id'” if($languages && preg_match('/\b(name\d*)_parent_id\b/', $exception->getMessage(), $matches)) { $nameField = $matches[1]; } else { $nameField = 'name'; } // get either 'name' or 'name123' (where 123 is language ID) $pageName = $page->get($nameField); $pageName = $this->pages->names()->incrementName($pageName); $page->set($nameField, $pageName); $query->bindValue(":$nameField", $sanitizer->pageName($pageName, Sanitizer::toAscii)); // indicate that page has a modified name $this->pages->names()->hasAdjustedName($page, true); return true; } /** * Save individual Page fields and supporting actions * * triggers hooks: saved, added, moved, renamed, templateChanged * * @param Page $page * @param bool $isNew * @param array $options * @return bool * @throws \Exception|WireException|\PDOException If any field-saving failure occurs while in a DB transaction * */ protected function savePageFinish(Page $page, $isNew, array $options) { $changes = $page->getChanges(2); $changesValues = $page->getChanges(true); // update children counts for current/previous parent if($isNew) { // new page $page->parent->numChildren++; } else if($page->parentPrevious && $page->parentPrevious->id != $page->parent->id) { // parent changed $page->parentPrevious->numChildren--; $page->parent->numChildren++; } // save any needed updates to pages_parents table $this->pages->parents()->save($page); // if page hasn't changed, don't continue further if(!$page->isChanged() && !$isNew) { $this->pages->debugLog('save', '[not-changed]', true); if(empty($options['noHooks'])) { $this->pages->saved($page, array()); $this->pages->savedPageOrField($page, array()); } return true; } // if page has a files path (or might have previously), trigger filesManager's save if(PagefilesManager::hasPath($page)) $page->filesManager->save(); // disable outputFormatting and save state $of = $page->of(); $page->of(false); // when a page is statusCorrupted, it records what fields are corrupted in _statusCorruptedFields array $corruptedFields = $page->hasStatus(Page::statusCorrupted) ? $page->_statusCorruptedFields : array(); // save each individual Fieldtype data in the fields_* tables foreach($page->fieldgroup as $field) { $fieldtype = $field->type; $name = $field->name; if($options['noFields'] || isset($corruptedFields[$name]) || !$fieldtype || !$page->hasField($field)) { unset($changes[$name]); unset($changesValues[$name]); } else { try { $fieldtype->savePageField($page, $field); } catch(\Exception $e) { $label = $field->getLabel(); $message = $e->getMessage(); if(strpos($message, $label) !== false) $label = $name; $error = sprintf($this->_('Error saving field "%s"'), $label) . ' — ' . $message; $this->trackException($e, true, $error); if($this->wire()->database->inTransaction()) throw $e; } } } // return outputFormatting state $page->of($of); // sortfield for children $templateSortfield = $page->template->sortfield; if(empty($templateSortfield)) $this->pages->sortfields()->save($page); if($options['resetTrackChanges']) { if($options['noFields']) { // reset for only fields that were saved foreach($changes as $change) $page->untrackChange($change); $page->setTrackChanges(true); } else { // reset all changes $page->resetTrackChanges(); } } // determine whether we'll trigger the added() hook if($isNew) { $page->setIsNew(false); $triggerAddedPage = $page; } else { $triggerAddedPage = null; } // check for template changes if($page->templatePrevious && $page->templatePrevious->id != $page->template->id) { // the template was changed, so we may have data in the DB that is no longer applicable // find unused data and delete it foreach($page->templatePrevious->fieldgroup as $field) { if($page->hasField($field)) continue; $field->type->deletePageField($page, $field); $this->message("Deleted field '$field' on page {$page->url}", Notice::debug); } } if($options['uncacheAll']) $this->pages->uncacheAll($page); // determine whether the pages_access table needs to be updated so that pages->find() // operations can be access controlled. if($isNew || $page->parentPrevious || $page->templatePrevious) $this->wire(new PagesAccess($page)); // trigger hooks if(empty($options['noHooks'])) { $this->pages->saved($page, $changes, $changesValues); $this->pages->savedPageOrField($page, $changes); if($triggerAddedPage) $this->pages->added($triggerAddedPage); if($page->namePrevious && $page->namePrevious != $page->name) $this->pages->renamed($page); if($page->parentPrevious) $this->pages->moved($page); if($page->templatePrevious) $this->pages->templateChanged($page); if(in_array('status', $changes)) $this->pages->statusChanged($page); } $this->pages->debugLog('save', $page, true); return true; } /** * TBD Identify if parent changed and call saveParentsTable() where appropriate * * @param Page $page Page to save parent(s) for * @param bool $isNew If page is newly created during this save this should be true, otherwise false * protected function savePageParent(Page $page, $isNew) { if($page->parentPrevious || $page->_forceSaveParents || $isNew) { $this->pages->parents()->rebuild($page); } // saveParentsTable option is always true unless manually disabled from a hook if($page->parentPrevious && !$isNew && $page->numChildren > 0) { // existing page was moved and it has children if($page->parent->numChildren == 1) { // first child of new parent $this->pages->parents()->rebuildPage($page->parent); } else { $this->pages->parents()->rebuildPage($page); } } else if(($page->parentPrevious && $page->parent->numChildren == 1) || ($isNew && $page->parent->numChildren == 1) || ($page->_forceSaveParents)) { // page is moved and is the first child of its new parent // OR page is NEW and is the first child of its parent // OR $page->_forceSaveParents is set (debug/debug, can be removed later) $this->pages->parents()->rebuildPage($page->parent); } else if($page->parentPrevious && $page->parent->numChildren > 1 && $page->parent->parent_id > 1) { $this->pages->parents()->rebuildPage($page->parent->parent); } if($page->parentPrevious && $page->parentPrevious->numChildren == 0) { // $page was moved and its previous parent is now left with no children, this ensures the old entries get deleted $this->pages->parents()->rebuild($page->parentPrevious->id); } } */ /** * Save just a field from the given page as used by Page::save($field) * * This function is public, but the preferred manner to call it is with $page->save($field) * * @param Page $page * @param string|Field $field Field object or name (string) * @param array|string $options Specify options: * - `quiet` (boolean): Specify true to bypass updating of modified_users_id and modified time (default=false). * - `noHooks` (boolean): Specify true to bypass calling of before/after save hooks (default=false). * @return bool True on success * @throws WireException * */ public function saveField(Page $page, $field, $options = array()) { $reason = ''; if(is_string($options)) $options = Selectors::keyValueStringToArray($options); if($page->isNew()) { throw new WireException("Can't save field from a new page - please save the entire page first"); } if(!$this->isSaveable($page, $reason, $field, $options)) { throw new WireException("Can't save field from page {$page->id}: {$page->path}: $reason"); } if($field && (is_string($field) || is_int($field))) { $field = $this->wire()->fields->get($field); } if(!$field instanceof Field) { throw new WireException("Unknown field supplied to saveField for page {$page->id}"); } if(!$page->fieldgroup->hasField($field)) { throw new WireException("Page {$page->id} does not have field {$field->name}"); } $value = $page->get($field->name); if($value instanceof Pagefiles || $value instanceof Pagefile) $page->filesManager()->save(); $page->trackChange($field->name); if(empty($options['noHooks'])) { $this->pages->saveFieldReady($page, $field); $this->pages->savePageOrFieldReady($page, $field->name); } if($field->type->savePageField($page, $field)) { $page->untrackChange($field->name); if(empty($options['quiet'])) { $user = $this->wire()->user; $userID = (int) ($user ? $user->id : $this->wire()->config->superUserPageID); $database = $this->wire()->database; $query = $database->prepare("UPDATE pages SET modified_users_id=:userID, modified=NOW() WHERE id=:pageID"); $query->bindValue(':userID', $userID, \PDO::PARAM_INT); $query->bindValue(':pageID', $page->id, \PDO::PARAM_INT); $database->execute($query); } $return = true; if(empty($options['noHooks'])) { $this->pages->savedField($page, $field); $this->pages->savedPageOrField($page, array($field->name)); } } else { $return = false; } $this->pages->debugLog('saveField', "$page:$field", $return); return $return; } /** * Silently add status flag to a Page and save * * This action does not update the Page modified date. * It updates the status for both the given instantiated Page object and the value in the DB. * * @param Page $page * @param int $status Use Page::status* constants * @return bool * @since 3.0.146 * @see PagesEditor::setStatus(), PagesEditor::removeStatus() * */ public function addStatus(Page $page, $status) { if(!$page->hasStatus($status)) $page->addStatus($status); return $this->savePageStatus($page, $status) > 0; } /** * Silently remove status flag from a Page and save * * This action does not update the Page modified date. * It updates the status for both the given instantiated Page object and the value in the DB. * * @param Page $page * @param int $status Use Page::status* constants * @return bool * @since 3.0.146 * @see PagesEditor::setStatus(), PagesEditor::addStatus(), PagesEditor::saveStatus() * */ public function removeStatus(Page $page, $status) { if($page->hasStatus($status)) $page->removeStatus($status); return $this->savePageStatus($page, $status, false, true) > 0; } /** * Silently save whatever the given Page’s status currently is * * This action does not update the Page modified date. * * @param Page $page * @return bool * @since 3.0.146 * */ public function saveStatus(Page $page) { return $this->savePageStatus($page, $page->status) > 0; } /** * Add or remove a Page status and commit to DB, optionally recursive with the children, grandchildren, and so on. * * While this can be performed with other methods, this is here just to make it fast for internal/non-api use. * See the trash and restore methods for an example. * * This action does not update the Page modified date. If given a Page or PageArray, also note that it does not update * the status properties of those instantiated Page objects, it only updates the DB value. * * #pw-internal Please use addStatus() or removeStatus() instead, unless you need to perform a recursive add/remove status. * * @param int|array|Page|PageArray $pageID Page ID, Page, array of page IDs, or PageArray * @param int $status Status per flags in Page::status* constants. Status will be OR'd with existing status, unless $remove is used. * @param bool $recursive Should the status descend into the page's children, and grandchildren, etc? (default=false) * @param bool|int $remove Should the status be removed rather than added? Use integer 2 to overwrite (default=false) * @return int Number of pages updated * */ public function savePageStatus($pageID, $status, $recursive = false, $remove = false) { $database = $this->wire()->database; $rowCount = 0; $multi = is_array($pageID) || $pageID instanceof PageArray; $status = (int) $status; if($status < 0 || $status > Page::statusMax) { throw new WireException("status must be between 0 and " . Page::statusMax); } $sql = "UPDATE pages SET status="; if($remove === 2) { // overwrite status (internal/undocumented) $sql .= "status=$status"; } else if($remove) { // remove status $sql .= "status & ~$status"; } else { // add status $sql .= "status|$status"; } if($multi && $recursive) { // multiple page IDs combined with recursive option, must be handled individually foreach($pageID as $id) { $rowCount += $this->savePageStatus((int) "$id", $status, $recursive, $remove); } // exit early in this case return $rowCount; } else if($multi) { // multiple page IDs without recursive option, can be handled in one query $ids = array(); foreach($pageID as $id) { $id = (int) "$id"; if($id > 0) $ids[$id] = $id; } if(!count($ids)) $ids[] = 0; $query = $database->prepare("$sql WHERE id IN(" . implode(',', $ids) . ")"); $database->execute($query); return $query->rowCount(); } else { // single page ID or Page object $pageID = (int) "$pageID"; $query = $database->prepare("$sql WHERE id=:page_id"); $query->bindValue(":page_id", $pageID, \PDO::PARAM_INT); $database->execute($query); $rowCount = $query->rowCount(); } if(!$recursive) return $rowCount; // recursive mode assumed from this point forward $parentIDs = array($pageID); do { $parentID = array_shift($parentIDs); // update all children to have the same status $query = $database->prepare("$sql WHERE parent_id=:parent_id"); $query->bindValue(":parent_id", $parentID, \PDO::PARAM_INT); $database->execute($query); $rowCount += $query->rowCount(); $query->closeCursor(); // locate children that themselves have children $query = $database->prepare( "SELECT pages.id FROM pages " . "JOIN pages AS pages2 ON pages2.parent_id=pages.id " . "WHERE pages.parent_id=:parent_id " . "GROUP BY pages.id " . "ORDER BY pages.sort" ); $query->bindValue(':parent_id', $parentID, \PDO::PARAM_INT); $database->execute($query); /** @noinspection PhpAssignmentInConditionInspection */ while($row = $query->fetch(\PDO::FETCH_ASSOC)) { $parentIDs[] = (int) $row['id']; } $query->closeCursor(); } while(count($parentIDs)); return $rowCount; } /** * Permanently delete a page and it's fields. * * Unlike trash(), pages deleted here are not restorable. * * If you attempt to delete a page with children, and don't specifically set the $recursive param to True, then * this method will throw an exception. If a recursive delete fails for any reason, an exception will be thrown. * * @param Page $page * @param bool|array $recursive If set to true, then this will attempt to delete all children too. * If you don't need this argument, optionally provide $options array instead. * @param array $options Optional settings to change behavior: * - `uncacheAll` (bool): Whether to clear memory cache after delete (default=false) * - `recursive` (bool): Same as $recursive argument, may be specified in $options array if preferred. * @return bool|int Returns true (success), or integer of quantity deleted if recursive mode requested. * @throws WireException on fatal error * */ public function delete(Page $page, $recursive = false, array $options = array()) { $defaults = array( 'uncacheAll' => false, 'recursive' => is_bool($recursive) ? $recursive : false, // internal use properties: '_level' => 0, '_deleteBranch' => false, ); if(is_array($recursive)) $options = $recursive; $options = array_merge($defaults, $options); $this->isDeleteable($page, true); // throws WireException $numDeleted = 0; $numChildren = $page->numChildren; $deleteBranch = false; if($numChildren) { if(!$options['recursive']) { throw new WireException("Can't delete Page $page because it has one or more children."); } if($options['_level'] === 0) { $deleteBranch = true; $options['_deleteBranch'] = $page; $this->pages->deleteBranchReady($page, $options); } foreach($page->children('include=all') as $child) { /** @var Page $child */ $options['_level']++; $result = $this->pages->delete($child, true, $options); $options['_level']--; if(!$result) throw new WireException("Error doing recursive page delete, stopped by page $child"); $numDeleted += $result; } } // trigger a hook to indicate delete is ready and WILL occur $this->pages->deleteReady($page, $options); $this->clear($page); $database = $this->wire()->database; $query = $database->prepare("DELETE FROM pages WHERE id=:page_id LIMIT 1"); // QA $query->bindValue(":page_id", $page->id, \PDO::PARAM_INT); $query->execute(); $this->pages->sortfields()->delete($page); $page->setTrackChanges(false); $page->status = Page::statusDeleted; // no need for bitwise addition here, as this page is no longer relevant $this->pages->deleted($page, $options); $numDeleted++; if($deleteBranch) $this->pages->deletedBranch($page, $options, $numDeleted); if($options['uncacheAll']) $this->pages->uncacheAll($page); $this->pages->debugLog('delete', $page, true); return $options['recursive'] ? $numDeleted : true; } /** * Clone an entire page (including fields, file assets, and optionally children) and return it. * * @param Page $page Page that you want to clone * @param Page $parent New parent, if different (default=same parent) * @param bool $recursive Clone the children too? (default=true) * @param array|string $options Optional options that can be passed to clone or save * - forceID (int): force a specific ID * - set (array): Array of properties to set to the clone (you can also do this later) * - recursionLevel (int): recursion level, for internal use only. * @return Page the newly cloned page or a NullPage() with id=0 if unsuccessful. * @throws WireException|\Exception on fatal error * */ public function _clone(Page $page, Page $parent = null, $recursive = true, $options = array()) { $defaults = array( 'forceID' => 0, 'set' => array(), 'recursionLevel' => 0, // recursion level (internal use only) ); if(is_string($options)) $options = Selectors::keyValueStringToArray($options); $options = array_merge($defaults, $options); if($parent === null) $parent = $page->parent; if(count($options['set']) && !empty($options['set']['name'])) { $name = $options['set']['name']; } else { $name = $this->pages->names()->uniquePageName(array( 'name' => $page->name, 'parent' => $parent )); } $of = $page->of(); $page->of(false); // Ensure all data is loaded for the page foreach($page->fieldgroup as $field) { if($page->hasField($field->name)) $page->get($field->name); } /** @var User $user */ $user = $this->wire('user'); // clone in memory $copy = clone $page; $copy->setIsNew(true); $copy->of(false); $copy->setQuietly('_cloning', $page); $copy->setQuietly('id', $options['forceID'] > 1 ? (int) $options['forceID'] : 0); $copy->setQuietly('numChildren', 0); $copy->setQuietly('created', time()); $copy->setQuietly('modified', time()); $copy->name = $name; $copy->parent = $parent; if(!isset($options['quiet']) || $options['quiet']) { $options['quiet'] = true; $copy->setQuietly('created_users_id', $user->id); $copy->setQuietly('modified_users_id', $user->id); } // set any properties indicated in options if(count($options['set'])) { foreach($options['set'] as $key => $value) { $copy->set($key, $value); // quiet option required for setting modified time or user if($key === 'modified' || $key === 'modified_users_id') $options['quiet'] = true; } } // tell PW that all the data needs to be saved foreach($copy->fieldgroup as $field) { if($copy->hasField($field)) $copy->trackChange($field->name); } $this->pages->cloneReady($page, $copy); $this->cloning++; $options['ignoreFamily'] = true; // skip family checks during clone try { $this->pages->save($copy, $options); } catch(\Exception $e) { $this->cloning--; $copy->setQuietly('_cloning', null); $page->of($of); throw $e; } $this->cloning--; // check to make sure the clone has worked so far if(!$copy->id || $copy->id == $page->id) { $copy->setQuietly('_cloning', null); $page->of($of); return $this->pages->newNullPage(); } // copy $page's files over to new page if(PagefilesManager::hasFiles($page)) { $copy->filesManager->init($copy); $page->filesManager->copyFiles($copy->filesManager->path()); } // if there are children, then recursively clone them too if($page->numChildren && $recursive) { $start = 0; $limit = 200; $numChildrenCopied = 0; do { $children = $page->children("include=all, start=$start, limit=$limit"); $numChildren = $children->count(); foreach($children as $child) { /** @var Page $child */ $childCopy = $this->pages->clone($child, $copy, true, array( 'recursionLevel' => $options['recursionLevel'] + 1, )); if($childCopy->id) $numChildrenCopied++; } $start += $limit; $this->pages->uncacheAll(); } while($numChildren); $copy->setQuietly('numChildren', $numChildrenCopied); } $copy->parentPrevious = null; $copy->setQuietly('_cloning', null); if($options['recursionLevel'] === 0) { // update pages_parents table, only when at recursionLevel 0 since parents()->rebuild() already descends if($copy->numChildren) { $copy->setIsNew(true); $this->pages->parents()->rebuild($copy); $copy->setIsNew(false); } // update sort if($copy->parent()->sortfield() == 'sort') { $this->sortPage($copy, $copy->sort, true); } } $copy->of($of); $page->of($of); $page->meta()->copyTo($copy->id); $copy->resetTrackChanges(); $this->pages->cloned($page, $copy); $this->pages->debugLog('clone', "page=$page, parent=$parent", $copy); return $copy; } /** * Update page modified/created/published time to now (or given time) * * @param Page|PageArray|array $pages May be Page, PageArray or array of page IDs (integers) * @param null|int|string|array $options Omit (null) to update to now, or unix timestamp or strtotime() recognized time string, * or if you do not need this argument, you may optionally substitute the $type argument here, * or in 3.0.183+ you can also specify array of options here instead: * - `time` (string|int|null): Unix timestamp or strtotime() recognized string to use, omit for use current time (default=null) * - `type` (string): One of 'modified', 'created', 'published' (default='modified') * - `user` (bool|User): True to also update modified/created user to current user, or specify User object to use (default=false) * @param string $type Date type to update, one of 'modified', 'created' or 'published' (default='modified') Added 3.0.147 * Skip this argument if using options array for previous argument or if using the default type 'modified'. * @throws WireException|\PDOException if given invalid format for $modified argument or failed database query * @return bool True on success, false on fail * */ public function touch($pages, $options = null, $type = 'modified') { $defaults = array( 'time' => (is_string($options) || is_int($options) ? $options : null), 'type' => $type, 'user' => false, ); $options = is_array($options) ? array_merge($defaults, $options) : $defaults; $database = $this->wire()->database; $time = $options['time']; $type = $options['type']; $user = $options['user'] === true ? $this->wire()->user : $options['user']; $ids = array(); if($time === 'modified' || $time === 'created' || $time === 'published') { // time argument was omitted and type supplied here instead $type = $time; $time = null; } // ensure $col property is created in this method and not copied directly from $type if($type === 'modified') { $col = 'modified'; } else if($type === 'created') { $col = 'created'; } else if($type === 'published') { $col = 'published'; } else { throw new WireException("Unrecognized date type '$type' for Pages::touch()"); } if($pages instanceof Page) { $ids[] = (int) $pages->id; } else if(WireArray::iterable($pages)) { foreach($pages as $page) { if(is_int($page)) { // page ID integer $ids[] = (int) $page; } else if($page instanceof Page) { // Page object $ids[] = (int) $page->id; } else if(ctype_digit("$page")) { // Page ID string $ids[] = (int) "$page"; } else { // invalid } } } if(!count($ids)) return false; $sql = "UPDATE pages SET $col="; if(is_null($time)) { $sql .= 'NOW() '; } else if(is_int($time) || ctype_digit($time)) { $time = (int) $time; $sql .= ':time '; } else if(is_string($time)) { $time = strtotime($time); if(!$time) throw new WireException("Unrecognized time format provided to Pages::touch()"); $sql .= ':time '; } if($user && $user instanceof User && ($col === 'modified' || $col === 'created')) { $sql .= ", {$col}_users_id=:user "; } $sql .= 'WHERE id IN(' . implode(',', $ids) . ')'; $query = $database->prepare($sql); if(strpos($sql, ':time')) $query->bindValue(':time', date('Y-m-d H:i:s', $time)); if(strpos($sql, ':user')) $query->bindValue(':user', $user->id, \PDO::PARAM_INT); return $database->execute($query); } /** * Move page to specified parent (work in progress) * * This method is the same as changing a page parent and saving, but provides a useful shortcut * for some cases with less code. This method: * * - Does not save the other custom fields of a page (if any are changed). * - Does not require that output formatting be off (it manages that internally). * * @param Page $child Page that you want to move. * @param Page|int|string $parent Parent to move it under (may be Page object, path string, or ID integer). * @param array $options Options to modify behavior (see PagesEditor::save for options). * @return bool|array True on success or false if not necessary. * @throws WireException if given parent does not exist, or move is not allowed * */ public function move(Page $child, $parent, array $options = array()) { if(is_string($parent) || is_int($parent)) $parent = $this->pages->get($parent); if(!$parent instanceof Page || !$parent->id) throw new WireException('Unable to locate parent for move'); $options['noFields'] = true; $of = $child->of(); $child->of(false); $child->parent = $parent; $result = $child->parentPrevious ? $this->pages->save($child, $options) : false; if($of) $child->of(true); return $result; } /** * Set page $sort value and increment siblings having same or greater sort value * * - This method is primarily applicable if configured sortfield is manual “sort” (or “none”). * - This is typically used after a move, sort, clone or delete operation. * * @param Page $page Page that you want to set the sort value for * @param int|null $sort New sort value for page or null to pull from $page->sort * @param bool $after If another page already has the sort, make $page go after it rather than before it? (default=false) * @throws WireException if given invalid arguments * @return int Number of sibling pages that had to have sort adjusted * */ public function sortPage(Page $page, $sort = null, $after = false) { $database = $this->wire()->database; // reorder siblings having same or greater sort value, when necessary if($page->id <= 1) return 0; if(is_null($sort)) $sort = $page->sort; // determine if any other siblings have same sort value $sql = 'SELECT id FROM pages WHERE parent_id=:parent_id AND sort=:sort AND id!=:id'; $query = $database->prepare($sql); $query->bindValue(':parent_id', $page->parent_id, \PDO::PARAM_INT); $query->bindValue(':sort', $sort, \PDO::PARAM_INT); $query->bindValue(':id', $page->id, \PDO::PARAM_INT); $query->execute(); $rowCount = $query->rowCount(); $query->closeCursor(); // move sort to after if requested if($after && $rowCount) $sort += $rowCount; // update $page->sort property if needed if($page->sort != $sort) $page->sort = $sort; // make sure that $page has the sort value indicated $sql = 'UPDATE pages SET sort=:sort WHERE id=:id'; $query = $database->prepare($sql); $query->bindValue(':sort', $sort, \PDO::PARAM_INT); $query->bindValue(':id', $page->id, \PDO::PARAM_INT); $query->execute(); $sortCnt = $query->rowCount(); // no need for $page to have 'sort' indicated as a change, since we just updated it above $page->untrackChange('sort'); if($rowCount) { // update order of all siblings $sql = 'UPDATE pages SET sort=sort+1 WHERE parent_id=:parent_id AND sort>=:sort AND id!=:id'; $query = $database->prepare($sql); $query->bindValue(':parent_id', $page->parent_id, \PDO::PARAM_INT); $query->bindValue(':sort', $sort, \PDO::PARAM_INT); $query->bindValue(':id', $page->id, \PDO::PARAM_INT); $query->execute(); $sortCnt += $query->rowCount(); } // call the sorted hook $this->pages->sorted($page, false, $sortCnt); return $sortCnt; } /** * Sort one page before another (for pages using manual sort) * * Note that if given $sibling parent is different from `$page` parent, then the `$pages->save()` * method will also be called to perform that movement. * * @param Page $page Page to move/sort * @param Page $sibling Sibling that page will be moved/sorted before * @param bool $after Specify true to make $page move after $sibling instead of before (default=false) * @throws WireException When conditions don't allow page insertions * */ public function insertBefore(Page $page, Page $sibling, $after = false) { $sortfield = $sibling->parent()->sortfield(); if($sortfield != 'sort') { throw new WireException('Insert before/after operations can only be used with manually sorted pages'); } if(!$sibling->id || !$page->id) { throw new WireException('New pages must be saved before using insert before/after operations'); } if($sibling->id == 1 || $page->id == 1) { throw new WireException('Insert before/after operations cannot involve homepage'); } $page->sort = $sibling->sort; if($page->parent_id != $sibling->parent_id) { // page needs to be moved first $page->parent = $sibling->parent; $page->save(); } $this->sortPage($page, $page->sort, $after); } /** * Rebuild the “sort” values for all children of the given $parent page, fixing duplicates and gaps * * If used on a $parent not currently sorted by by “sort” then it will update the “sort” index to be * consistent with whatever the pages are sorted by. * * @param Page $parent * @return int * */ public function sortRebuild(Page $parent) { if(!$parent->id || !$parent->numChildren) return 0; $database = $this->wire()->database; $sorts = array(); $sort = 0; if($parent->sortfield() == 'sort') { // pages are manually sorted, so we can find IDs directly from the database $sql = 'SELECT id FROM pages WHERE parent_id=:parent_id ORDER BY sort, created'; $query = $database->prepare($sql); $query->bindValue(':parent_id', $parent->id, \PDO::PARAM_INT); $query->execute(); // establish new sort values do { $id = (int) $query->fetch(\PDO::FETCH_COLUMN); if(!$id) break; $sorts[] = "($id,$sort)"; } while(++$sort); $query->closeCursor(); } else { // children of $parent don't currently use "sort" as sort property // so we will update the "sort" of children to be consistent with that // of whatever sort property is in use. $o = array('findIDs' => 1, 'cache' => false); foreach($parent->children('include=all', $o) as $id) { $id = (int) $id; $sorts[] = "($id,$sort)"; $sort++; } } // update sort values $query = $database->prepare( 'INSERT INTO pages (id,sort) VALUES ' . implode(',', $sorts) . ' ' . 'ON DUPLICATE KEY UPDATE sort=VALUES(sort)' ); $query->execute(); return count($sorts); } /** * Replace one page with another (work in progress) * * @param Page $oldPage * @param Page $newPage * @return Page * @throws WireException * @since 3.0.189 But not yet available in public API * */ protected function replace(Page $oldPage, Page $newPage) { if($newPage->numChildren) { throw new WireException('Page with children cannot replace another'); } $database = $this->wire()->database; $this->pages->cacher()->uncache($oldPage); $this->pages->cacher()->uncache($newPage); $prevId = $newPage->id; $id = $oldPage->id; $parent = $oldPage->parent; $prevTemplate = $oldPage->template; $newPage->parent = $parent; $newPage->templatePrevious = $prevTemplate; $this->clear($oldPage, array( 'clearParents' => false, 'clearAccess' => $prevTemplate->id != $newPage->template->id, 'clearSortfield' => false, )); $binds = array( ':id' => $id, ':parent_id' => $parent->id, ':prev_id' => $prevId, ); $sqls = array(); $sqls[] = 'UPDATE pages SET id=:id, parent_id=:parent_id WHERE id=:prev_id'; foreach($newPage->template->fieldgroup as $field) { /** @var Field $field */ $field->type->replacePageField($newPage, $oldPage, $field); } foreach($sqls as $sql) { $query = $database->prepare($sql); foreach($binds as $bindKey => $bindValue) { if(strpos($sql, $bindKey) === false) continue; $query->bindValue($bindKey, $bindValue); $query->execute(); } } $newPage->id = $id; $this->save($newPage); $page = $this->pages->getById($id, $newPage->template, $parent->id); return $page; } /** * Clear a page of its data * * @param Page $page * @param array $options * @return bool * @throws WireException * @since 3.0.189 * */ public function clear(Page $page, array $options = array()) { $defaults = array( 'clearMethod' => 'delete', // 'delete' or 'empty' 'haltOnError' => false, 'clearFields' => true, 'clearFiles' => true, 'clearMeta' => true, 'clearAccess' => true, 'clearSortfield' => true, 'clearParents' => true, ); $options = array_merge($defaults, $options); $errors = array(); $halt = false; if($options['clearFields']) { foreach($page->fieldgroup as $field) { /** @var Field $field */ if($options['clearMethod'] === 'delete') { $result = $field->type->deletePageField($page, $field); } else { $result = $field->type->emptyPageField($page, $field); } if(!$result) { $errors[] = "Unable to clear field '$field' from page $page"; $halt = $options['haltOnError']; if($halt) break; } } } if($options['clearFiles'] && !$halt) { $error = "Error clearing files for page $page"; try { if(PagefilesManager::hasPath($page)) { if(!$page->filesManager->emptyAllPaths()) { $errors[] = $error; $halt = $options['haltOnError']; } } } catch(\Exception $e) { $errors[] = $error . ' - ' . $e->getMessage(); $halt = $options['haltOnError']; } } if($options['clearMeta'] && !$halt) { try { $page->meta()->removeAll(); } catch(\Exception $e) { $errors[] = "Error clearing meta for page $page"; $halt = $options['haltOnError']; } } if($options['clearAccess'] && !$halt) { /** @var PagesAccess $access */ $access = $this->wire(new PagesAccess()); $access->deletePage($page); } if($options['clearParents'] && !$halt) { // delete entirely from pages_parents table $this->pages->parents()->delete($page); } if($options['clearSortfield'] && !$halt) { $this->pages->sortfields()->delete($page); } if(count($errors) || $halt) { foreach($errors as $error) { $this->error($error); } return false; } return true; } /** * Prepare options for Pages::new(), Pages::newPage() * * Converts given array, selector string, template name, object or int to array of options. * * #pw-internal * * @param array|string|int $options * @return array * @since 3.0.191 * */ public function newPageOptions($options) { if(empty($options)) return array(); $template = null; /** @var Template|null $template */ $parent = null; $class = ''; if(is_array($options)) { // ok } else if(is_string($options)) { if(strpos($options, '=') !== false) { $selectors = new Selectors($options); $this->wire($selectors); $options = array(); foreach($selectors as $selector) { $options[$selector->field()] = $selector->value; } } else if(strpos($options, '/') === 0) { $options = array('path' => $options); } else { $options = array('template' => $options); } } else if(is_object($options)) { $options = $options instanceof Template ? array('template' => $options) : array(); } else if(is_int($options)) { $template = $this->wire()->templates->get($options); $options = $template ? array('template' => $template) : array(); } else { $options = array(); } // only use property 'parent' rather than 'parent_id' if(!empty($options['parent_id']) && empty($options['parent'])) { $options['parent'] = $options['parent_id']; unset($options['parent_id']); } // only use property 'template' rather than 'templates_id' if(!empty($options['templates_id']) && empty($options['template'])) { $options['template'] = $options['templates_id']; unset($options['templates_id']); } // page class (pageClass) if(!empty($options['pageClass'])) { // ok $class = $options['pageClass']; unset($options['pageClass']); } else if(!empty($options['class']) && !$this->wire()->fields->get('class')) { // alias for pageClass, so long as there is not a field named 'class' $class = $options['class']; unset($options['class']); } // identify requested template if(isset($options['template'])) { $template = $options['template']; if(!is_object($template)) { $template = empty($template) ? null : $this->wire()->templates->get($template); } unset($options['template']); } // convert parent path to parent page object if(!empty($options['parent'])) { if(is_object($options['parent'])) { $parent = $options['parent']; } else if(ctype_digit("$options[parent]")) { $parent = (int) $options['parent']; } else { $parent = $this->pages->getByPath($options['parent']); if(!$parent->id) $parent = null; } unset($options['parent']); } // name and parent can be detected from path, when specified if(!empty($options['path'])) { $path = trim($options['path'], '/'); if(strpos($path, '/') === false) $path = "/$path"; $parts = explode('/', $path); // note index[0] is blank $name = array_pop($parts); if(empty($options['name']) && !empty($name)) { // detect name from path $options['name'] = $name; } if(empty($parent) && !$this->pages->loader()->isLoading()) { // detect parent from path $parentPath = count($parts) ? implode('/', $parts) : '/'; $parent = $this->pages->getByPath($parentPath); if(!$parent->id) $parent = null; } unset($options['path']); } // detect template from parent (when possible) if(!$template && !empty($parent) && empty($options['id']) && !$this->pages->loader()->isLoading()) { $parent = is_object($parent) ? $parent : $this->pages->get($parent); if($parent->id) { if(count($parent->template->childTemplates) === 1) { $template = $parent->template->childTemplates()->first(); } } else { $parent = null; } } // detect parent from template (when possible) if($template && empty($parent) && empty($options['id']) && !$this->pages->loader()->isLoading()) { if(count($template->parentTemplates) === 1) { $parentTemplates = $template->parentTemplates(); if($parentTemplates->count()) { $numParents = $this->pages->count("template=$parentTemplates, include=all"); if($numParents === 1) { $parent = $this->pages->get("template=$parentTemplates"); if(!$parent->id) $parent = null; } } } } // detect class from template if(empty($class) && $template) $class = $template->getPageClass(); if($parent) $options['parent'] = $parent; if($template) $options['template'] = $template; if($class) $options['pageClass'] = $class; if(isset($options['id'])) { if(ctype_digit("$options[id]") && (int) $options['id'] > 0) { $options['id'] = (int) $options['id']; if($parent && "$options[id]" === "$parent") unset($options['parent']); } else if(((int) $options['id']) === -1) { $options['id'] = (int) $options['id']; // special case allowed for access control tests } else { unset($options['id']); } } return $options; } /** * Hook after Fieldtype::sleepValue to remove MB4 characters when present and applicable * * This hook is only used if $config->dbStripMB4 is true and $config->dbEngine is not “utf8mb4”. * * @param HookEvent $event * */ public function hookFieldtypeSleepValueStripMB4(HookEvent $event) { $event->return = $this->wire()->sanitizer->removeMB4($event->return); } }