artabro/wire/core/PagesEditor.php
2024-08-27 11:35:37 +02:00

1920 lines
64 KiB
PHP
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php namespace ProcessWire;
/**
* ProcessWire Pages Editor
*
* Implements page manipulation methods of the $pages API variable
*
* ProcessWire 3.x, Copyright 2021 by Ryan Cramer
* https://processwire.com
*
*/
class PagesEditor extends Wire {
/**
* Are we currently cloning a page?
*
* This is greater than 0 only when the clone() method is currently in progress.
*
* @var int
*
*/
protected $cloning = 0;
/**
* @var Pages
*
*/
protected $pages;
/**
* Construct
*
* @param Pages $pages
*
*/
public function __construct(Pages $pages) {
parent::__construct();
$this->pages = $pages;
$config = $pages->wire()->config;
if($config->dbStripMB4 && strtolower($config->dbCharset) != '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($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 = ($parent->id ? $parent->numChildren() : 0);
}
// assign any default values for fields
foreach($page->template->fieldgroup as $field) {
/** @var Field $field */
if($page->isLoaded($field->name)) continue; // value already set
if(!$page->hasField($field)) continue; // field not valid for page
if(!strlen((string) $field->get('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=true)
* - `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' => true,
'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 && "$user->language") {
$language = $user->language;
$user->setLanguage($languages->getDefault());
}
$reason = '';
$isNew = $page->isNew();
if($isNew) $this->pages->setupNew($page);
if(!$this->isSaveable($page, $reason, '', $options)) {
if($language) $user->setLanguage($language);
throw new WireException(rtrim("Cant save page (id=$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);
}
}
if($options['adjustName']) $this->pages->names()->checkNameConflicts($page);
if(!$this->savePageQuery($page, $options)) return false;
$result = $this->savePageFinish($page, $isNew, $options);
if($language) $user->setLanguage($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);
}
}
$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) {
/** @var Field $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)) {
// if page has a files path (or might have previously), trigger filesManager's save
if(PagefilesManager::hasPath($page)) $page->filesManager->save();
$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 Pages 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|NullPage 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) {
/** @var Field $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 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 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 */
/** @var Fieldtype $fieldtype */
$fieldtype = $field->type;
if($options['clearMethod'] === 'delete') {
$result = $fieldtype->deletePageField($page, $field);
} else {
$result = $fieldtype->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)) {
$filesManager = $page->filesManager();
if(!$filesManager) {
// $filesManager will be null if page has deleted status
// so create our own instance
$filesManager = new PagefilesManager($page);
}
if(!$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);
}
}