From f4592435aabe1c79890b328a15dac3814a827df0 Mon Sep 17 00:00:00 2001 From: Ryan Cramer Date: Fri, 10 Dec 2021 10:39:26 -0500 Subject: [PATCH] Add a $pages->new() API method which is similar to $pages->add() in that it creates a new page in the DB, except that the new() method accepts a selector string (or array) rather than the specific arugments if the add() method The new() method can also figure out the appropriate template or parent on its own (if available in family settings). This commit also updates the existing $pages->newPage() method (which creates a new page in memory only) to also accept the same selector string/array as the new() method. These updates are thoroughly phpdoc'd with the methods. --- wire/core/FieldtypeMulti.php | 5 +- wire/core/Page.php | 1 + wire/core/Pages.php | 183 +++++++++++++++++++++++++++-------- wire/core/PagesEditor.php | 159 +++++++++++++++++++++++++++++- 4 files changed, 300 insertions(+), 48 deletions(-) diff --git a/wire/core/FieldtypeMulti.php b/wire/core/FieldtypeMulti.php index 108fabc3..abf2b423 100644 --- a/wire/core/FieldtypeMulti.php +++ b/wire/core/FieldtypeMulti.php @@ -452,12 +452,13 @@ abstract class FieldtypeMulti extends Fieldtype { */ public function getLoadQuery(Field $field, DatabaseQuerySelect $query) { - $database = $this->wire('database'); + $database = $this->wire()->database; + $sanitizer = $this->wire()->sanitizer; + $table = $database->escapeTable($field->table); $schemaAll = $this->getDatabaseSchema($field); $schema = $this->trimDatabaseSchema($schemaAll); $fieldName = $database->escapeCol($field->name); - $sanitizer = $this->wire('sanitizer'); $orderByCols = array(); $start = null; $limit = null; diff --git a/wire/core/Page.php b/wire/core/Page.php index fc5c117e..31f898fd 100644 --- a/wire/core/Page.php +++ b/wire/core/Page.php @@ -97,6 +97,7 @@ * * * @property Page|null $_cloning Internal runtime use, contains Page being cloned (source), when this Page is the new copy (target). #pw-internal + * @property int |null $_inserted Populated with time() value of when new page was inserted into DB, only for page created in this request. #pw-internal * @property bool|null $_hasAutogenName Internal runtime use, set by Pages class when page as auto-generated name. #pw-internal * @property bool|null $_forceSaveParents Internal runtime/debugging use, force a page to refresh its pages_parents DB entries on save(). #pw-internal * @property float|null $_pfscore Internal PageFinder fulltext match score when page found/loaded from relevant query. #pw-internal diff --git a/wire/core/Pages.php b/wire/core/Pages.php index e26469c0..b842ccf2 100644 --- a/wire/core/Pages.php +++ b/wire/core/Pages.php @@ -841,7 +841,9 @@ class Pages extends Wire { /** * Add a new page using the given template and parent * - * If no page "name" is specified, one will be automatically assigned. + * If no page “name” is specified, one will be automatically assigned. + * + * For an alternate interface for adding new pages, see the `$pages->new()` method. * * ~~~~~ * // Add new page using 'skyscraper' template into Atlanta @@ -859,7 +861,7 @@ class Pages extends Wire { * ]); * ~~~~~ * - * #pw-group-manipulation + * #pw-group-creation * * @param string|Template $template Template name or Template object * @param string|int|Page $parent Parent path, ID or Page object @@ -868,11 +870,83 @@ class Pages extends Wire { * @param array $values Field values to assign to page (optional). If $name is omitted, this may also be 3rd param. * @return Page New page ready to populate. Note that this page has output formatting off. * @throws WireException When some criteria prevents the page from being saved. + * @see Pages::new(), Pages::newPage() * */ public function ___add($template, $parent, $name = '', array $values = array()) { return $this->editor()->add($template, $parent, $name, $values); } + + /** + * Create a new Page populated from selector string or array + * + * This is similar to the `$pages->add()` method but with a simpler 1-argument (selector) interface. + * This method can also auto-detect some properties that the add() method cannot. + * + * To create a new page without saving to the database use the `$pages->newPage()` method instead. + * It accepts the same arguments as this method. + * + * Minimum requirements to create a new page that is saved in database: + * + * - A `template` must be specified, unless it can be auto-detected from a given `parent`. + * - A `parent` must be specified, unless it can be auto-detected from a given `template` or `path`. + * + * Please note the following: + * + * - If a `path` is specified but not a `name` or `parent` then both will be derived from the `path`. + * - If a `title` is specified but not a `name` or `path` then the `name` will be derived from the `title`. + * - If given `parent` or `path` only allows one template (via family settings) then `template` becomes optional. + * - If given `template` only allows one parent (via family settings) then `parent` becomes optional. + * - If given selector string starts with a `/` it is assumed to be the `path` property. + * - If new page has name that collides with an existing page (i.e. “foo”), new page name will increment (i.e. “foo-1”). + * - If no `name`, `path` or `title` is given (that name can be derived from) then an “untitled-page” name will be used. + * + * ~~~~~ + * // Creating a page via selector string + * $p = $pages->new("template=category, parent=/categories/, title=New Category"); + * + * // Creating a page via selector using path, which implies parent and name + * $p = $pages->new("template=category, path=/categories/new-category"); + * + * // Creating a page via array + * $p = $pages->new([ + * 'template' => 'category', + * 'parent' => '/categories/', + * 'title' => 'New Category' + * ]); + * + * // Parent and name can be auto-detected when you specify path… + * $p = $pages->new('path=/blog/posts/foo-bar-baz'); + * + * // …and even 'path=' is optional if slash '/' is at beginning + * $p = $pages->new('/blog/posts/foo-bar-baz'); + * ~~~~~ + * + * #pw-group-creation + * + * @param string|array $selector Selector string or array of properties to set + * @return Page + * @since 3.0.191 + * @see Pages::add(), Pages::newPage() + * + */ + public function ___new($selector = '') { + + $options = $this->editor()->newPageOptions($selector); + $error = 'Cannot save new page without a %s'; + $template = isset($options['template']) ? $options['template'] : null; + $parent = isset($options['parent']) ? $options['parent'] : null; + $name = isset($options['name']) ? $options['name'] : ''; + + if(!$template) throw new WireException(sprintf($error, 'template')); + if(!$parent) throw new WireException(sprintf($error, 'parent')); + + unset($options['template'], $options['parent'], $options['name']); + + $page = $this->editor()->add($template, $parent, $name, $options); + + return $page; + } /** * Clone entire page return it. @@ -897,7 +971,7 @@ class Pages extends Wire { * $copy->save(); * ~~~~~ * - * #pw-group-manipulation + * #pw-group-creation * * @param Page $page Page that you want to clone * @param Page|null $parent New parent, if different (default=null, which implies same parent) @@ -1714,6 +1788,7 @@ class Pages extends Wire { * * @param array $options Optionally specify ONE of the following: * - `pageArrayClass` (string): Name of PageArray class to use (if not “PageArray”). + * - `class` (string): Alias of pageArrayClass supported in 3.0.191+. * - `pageArray` (PageArray): Wire and return this given PageArray, rather than instantiating a new one. * @return PageArray * @@ -1724,7 +1799,11 @@ class Pages extends Wire { return $options['pageArray']; } $class = 'PageArray'; - if(!empty($options['pageArrayClass'])) $class = $options['pageArrayClass']; + if(!empty($options['pageArrayClass'])) { + $class = $options['pageArrayClass']; + } else if(!empty($options['class'])) { + $class = $options['class']; + } $class = wireClassName($class, true); $pageArray = $this->wire(new $class()); if(!$pageArray instanceof PageArray) $pageArray = $this->wire(new PageArray()); @@ -1732,54 +1811,70 @@ class Pages extends Wire { } /** - * Return a new/blank Page object (in memory only) + * Return a new Page object without saving it to the database * - * #pw-internal + * To create a new Page object and save it the database, use the `$pages->new()` or `$pages->add()` methods, + * or call `save()` on the Page object returned from this method. * - * @param array|string|Template $options Optionally specify array of any of the following: - * - `template` (Template|id|string): Template to use via object, ID or name. - * - `pageClass` (string): Class to use for Page. If not specified, default is from template setting, or 'Page' if no template. - * - Any other Page properties or fields you want to set (parent, name, title, etc.). Note that most page fields will need to + * - When a template is specified, the `pageClass` can be auto-detected from the template. + * - In 3.0.152+ you may specify the Template object, name or ID instead of an $options array. + * - In 3.0.191+ you may specify a selector string for the $options argument (alternative to array), + * see the `$pages->new()` method `$selector` argument for details. + * - In 3.0.191+ the `pageClass` can also be specified as `class`, assuming that doesn’t collide + * with an existing field name. + * + * ~~~~~ + * // Create a new blank Page object + * $p = $pages->newPage(); + * + * // Create a new Page object and specify properties to set with an array + * $p = $pages->newPage([ + * 'template' => 'blog-post', + * 'parent' => '/blog/posts/', + * 'title' => 'Hello world', + * ]); + * + * // Same as above but using selector string (3.0.191+) + * $p = $pages->newPage('template=blog-post, parent=/blog/posts, title=Hello world'); + * + * // Create new Page object using 'blog-post' template + * $p = $pages->newPage('blog-post'); + * + * // Create new Page object with parent and name implied by given path (3.0.191+) + * $p = $pages->newPage('/blog/posts/hello-world'); + * ~~~~~ + * + * #pw-group-creation + * + * @param array|string|Template $options Optionally specify array (or selector string in 3.0.191+) with any of the following: + * - `template` (Template|id|string): Template to use via object, ID or name. The `pageClass` will be auto-detected. + * - `parent` (Page|int|string): Parent page object, ID or path. + * - `name` (string): Name of page. + * - `path` (string): Specify /path/for/page/, name and parent (and maybe template) can be auto-detected. 3.0.191+ + * - `pageClass` (string): Class to use for Page. If not specified, default is from template setting, or `Page` if no template. + * - Specify any other Page properties or fields you want to set (name, title, etc.). Note that most page fields will need to * have a `template` set first, so make sure to include it in your options array when providing other fields. - * - In PW 3.0.152+ you may specify the Template object, name or ID instead of an $options array. * @return Page * */ public function newPage($options = array()) { - if(!is_array($options)) { - if(is_object($options) && $options instanceof Template) { - $options = array('template' => $options); - } else if($options && (is_string($options) || is_int($options))) { - $options = array('template' => $options); - } else { - $options = array(); - } - } - if(!empty($options['pageClass'])) { - $class = $options['pageClass']; - } else { - $class = 'Page'; - } - if(!empty($options['template'])) { - $template = $options['template']; - if(!is_object($template)) { - $template = empty($template) ? null : $this->wire('templates')->get($template); - } - if($template && empty($options['pageClass'])) { - $class = $template->getPageClass(); - } - } else { - $template = null; - } + if(empty($options)) return $this->wire(new Page()); + + $options = $this->editor()->newPageOptions($options); + $template = isset($options['template']) ? $options['template'] : null; + $parent = isset($options['parent']) ? $options['parent'] : null; + $class = empty($options['pageClass']) ? 'Page' : $options['pageClass']; + + unset($options['template'], $options['parent'], $options['pageClass']); + if(strpos($class, "\\") === false) $class = wireClassName($class, true); - $page = $this->wire(new $class($template)); - if(!$page instanceof Page) $page = $this->wire(new Page($template)); - unset($options['pageClass'], $options['template']); - foreach($options as $name => $value) { - $page->set($name, $value); - } + $page = $this->wire(new $class($template)); + + if(!$page instanceof Page) $page = $this->wire(new Page($template)); + if($parent && $parent->id) $page->parent = $parent; + if(count($options)) $page->setArray($options); return $page; } @@ -2280,8 +2375,10 @@ class Pages extends Wire { $status = $page->status; $statusPrevious = $page->statusPrevious; $isPublished = !$page->isUnpublished(); + $isInserted = $page->_inserted > 0; $wasPublished = !($statusPrevious & Page::statusUnpublished); - if($isPublished && !$wasPublished) $this->published($page); + + if($isPublished && (!$wasPublished || $isInserted)) $this->published($page); if(!$isPublished && $wasPublished) $this->unpublished($page); $from = array(); diff --git a/wire/core/PagesEditor.php b/wire/core/PagesEditor.php index 9c223423..a2778084 100644 --- a/wire/core/PagesEditor.php +++ b/wire/core/PagesEditor.php @@ -81,8 +81,12 @@ class PagesEditor extends Wire { if(!$template) throw new WireException("Unknown template"); } - $page = $this->pages->newPage($template); - $page->parent = $parent; + $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}'."; @@ -120,11 +124,13 @@ class PagesEditor extends Wire { // 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; @@ -596,7 +602,11 @@ class PagesEditor extends Wire { } } while($keepTrying && (++$tries < $maxTries)); - if($result && ($isNew || !$page->id)) $page->id = (int) $database->lastInsertId(); + if($result && ($isNew || !$page->id)) { + $page->id = (int) $database->lastInsertId(); + $page->setQuietly('_inserted', time()); + } + if($options['forceID']) $page->id = (int) $options['forceID']; return $result; @@ -1724,6 +1734,149 @@ class PagesEditor extends Wire { 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)) { + // 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)) { + $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) && 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"); + } + } + } + + // 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; + + unset($options['id']); // just in case it was there + + return $options; + } /** * Hook after Fieldtype::sleepValue to remove MB4 characters when present and applicable