Rev 22 | Blame | Compare with Previous | Last modification | View Log | Download
<?php namespace ProcessWire;/*** ProcessWire Repeater Fieldtype** Maintains a collection of fields that are repeated for any number of times.** For documentation about how Fieldtypes work, see:* /wire/core/Fieldtype.php* /wire/core/FieldtypeMulti.php** ProcessWire 3.x, Copyright 2022 by Ryan Cramer* https://processwire.com** @todo: automatic sorting.** @property int $repeatersRootPageID* @method saveConfigInputfields(Field $field, Template $template, Page $parent)* @method readyPageSaved(Page $readyPage, Page $ownerPage, Field $field) Hook called when ready page is saved** Page status notes for repeater items:* - Unpublished & Hidden: Ready page, not yet used. Appears in unformatted repeater PageArray but user has not saved it.* - Unpublished & On: Publish requested and can be published as long as no input errors.* - Unpublished & NOT On: Item has been unpublished.** Unpublished or hidden pages do not appear in formatted PageArray value, only in unformatted.**/class FieldtypeRepeater extends Fieldtype implements ConfigurableModule {public static function getModuleInfo() {return array('title' => __('Repeater', __FILE__), // Module Title'summary' => __('Maintains a collection of fields that are repeated for any number of times.', __FILE__), // Module Summary'version' => 112,'autoload' => true,'installs' => 'InputfieldRepeater');}const devMode = false; // display verbose TD messagesconst templateNamePrefix = 'repeater_';const fieldPageNamePrefix = 'for-field-';const repeaterPageNamePrefix = 'for-page-';const defaultRepeaterMaxItems = 0;const repeatersRootPageName = 'repeaters';const collapseExisting = 0;const collapseNone = 3;const collapseAll = 1;const loadingNew = 0;const loadingAll = 1;const loadingOff = 2;/*** Field names used by repeaters in format [ PW_instanceID => [ 'field_name', 'field_name2' ] ];** @var array**/static protected $fieldsUsedInRepeaters = array();/*** Template IDs used by repeaters in format [ PW_instanceID => [ 123, 456, 789 ] ]** @var array**/static protected $templatesUsedByRepeaters = array();/*** Has ready method been called? [ PW_instanceID => true | false ]** @var bool**/static protected $isReady = array();/*** Fields that are initialized [ PW_instanceID => [ 'field_id' => true ] ]** @var bool**/static protected $initFields = array();/*** ProcessWire instance ID** @var int**/protected $instanceID = 0;/*** When non-zero, a deletePageField function call occurred and we shouldn't re-create any repeater parents** The value it contains is the ID of the parent page used by the field for repeater items**/protected $deletePageField = 0;/*** Page assigned by our ProcessPageEdit::ajaxSave hook, kept for comparison for editable() access** @var Page**/protected $ajaxPage;/*** Name of field that appeared in HTTP_X_FIELDNAME, before it was modified**/protected $ajaxFieldName = '';/*** Use lazy loading mode?** @var null|bool**/protected $useLazy = null;/*** Construct the Repeater Fieldtype**/public function __construct() {require_once(dirname(__FILE__) . '/RepeaterPage.php');require_once(dirname(__FILE__) . '/RepeaterPageArray.php');$this->set('repeatersRootPageID', 0);parent::__construct();}/*** Setup a hook to Pages::delete so that we can remove references when pages are deleted**/public function init() {$this->instanceID = $this->wire()->getProcessWireInstanceID();self::$initFields[$this->instanceID] = array();$this->wire()->pages->addHookAfter('deleteReady', $this, 'hookPagesDelete');$this->useLazy = $this->wire()->config->useLazyLoading;parent::init();}/*** Setup a hook so that we can keep ajax saves working with ProcessPageEdit**/public function ready() {parent::ready();if(!empty(self::$isReady[$this->instanceID])) return; // ensures everything below only runs only once (for extending types)self::$isReady[$this->instanceID] = true;$page = $this->wire()->page;$process = $page->process; /** @var Process|null $process */$user = $this->wire()->user;$config = $this->wire()->config;$input = $this->wire()->input;$modules = $this->wire()->modules;$inEditor = wireInstanceOf($process, 'ProcessPageEdit') || $process == 'ProcessProfile';$isSuperuser = $user->isSuperuser();// @todo would the following line be needed in some contexts (like ListerPro?)// if(!$inEditor && $process && wireInstanceOf($process, 'WirePageEditor')) $inEditor = true;// make sure that all templates used by repeater pages enforce a Page type of RepeaterPage// this was necessary when lazy loading option was disabledif(!$this->useLazy) $this->initAllFields();if($inEditor) {// ProcessPageEdit or ProcessProfile$this->addHookBefore('ProcessPageEdit::ajaxSave', $this, 'hookProcessPageEditAjaxSave', array('priority' => 99));}if($inEditor && $config->ajax) {// handle scenario of repeater within repeater field$fieldName = (string) $input->get('field');$pageID = (int) $input->get('id');if($pageID && strpos($fieldName, '_repeater') && preg_match('/^(.+)_repeater\d+($|\.)/', $fieldName, $matches)) {$this->initAllFields();$editPage = $this->wire()->pages->get($pageID);if($editPage->id && strpos($editPage->template->name, self::templateNamePrefix) === 0) {// update field name to exclude the _repeater1234 part at the end, so that PageEdit recognizes it$input->get->__set('field', $this->wire()->sanitizer->fieldName($matches[1]));}}// handle scenario of file upload or other ajax saved fieldif(isset($_SERVER['HTTP_X_FIELDNAME'])) {// initialize all repeater fields so RepeaterPage class names are active for access controlif(strpos($_SERVER['HTTP_X_FIELDNAME'], '_repeater')) $this->initAllFields();}}if(!$inEditor && !$user->isGuest() && !$isSuperuser && $user->hasPermission('page-edit')) {// allow for front-end editor to also trigger an inEditor=true conditionif(strpos($page->url, $config->urls->admin) === false && $page->editable()) {if($this->wire()->modules->isInstalled('PageFrontEdit')) $inEditor = true;}}if($inEditor && !$isSuperuser) {// need an extra hook to handle permissions$this->addHookAfter('PagePermissions::pageEditable', $this, 'hookPagePermissionsPageEditableAjax');}$this->addHookBefore('PageFinder::getQuery', $this, 'hookPageFinderGetQuery');$class = $this->className() . 'Matrix';if($this->useLazy && $modules->isInstalled($class)) $modules->get($class);}/*** Called when field of this type is initialized at boot or after lazy loaded** #pw-internal** @param Field $field* @since 3.0.194**/public function initField(Field $field) {if(!empty(self::$initFields[$this->instanceID][$field->id])) return;parent::initField($field);if(!$this->useLazy) return;self::$initFields[$this->instanceID][$field->id] = true;/** @var FieldtypeRepeater $fieldtype */$fieldtype = $field->type;if(!$fieldtype instanceof FieldtypeRepeater) return;$template = $fieldtype->getRepeaterTemplate($field);if(!$template) return;$class = $fieldtype->getPageClass();if(__NAMESPACE__ && $class) $class = wireClassName($class);$_class = $template->get('pageClass');if($class === $_class) return;$template->set('pageClass', $class);$template->save();}/*** Force initialize of all repeater fields, confirming their configuration settings are correct** @since 3.0.199**/public function initAllFields() {if(!empty(self::$initFields['*'])) return;self::$initFields['*'] = true;$repeaterFields = $this->wire()->fields->findByType('FieldtypeRepeater', array('inherit' => true,'valueType' => 'field','indexType' => '',));$useLazy = $this->useLazy;$this->useLazy = true;foreach($repeaterFields as $field) {$this->initField($field);}$this->useLazy = $useLazy;}/*** Get class name to use Field objects of this type (must be class that extends Field class)** Return blank if default class (Field) should be used.** @param array $a Field data from DB (if needed)* @return string Return class name or blank to use default Field class* @since 3.0.146**/public function getFieldClass(array $a = array()) {require_once(dirname(__FILE__) . '/RepeaterField.php');return 'RepeaterField';}/*** Get the class used for repeater Page objects** @return string**/public function getPageClass() {return __NAMESPACE__ . "\\RepeaterPage";}/*** Get the class used for repeater PageArray objects** @return string**/public function getPageArrayClass() {return __NAMESPACE__ . "\\RepeaterPageArray";}/*** Hook called after PagePermissions::pageEditable() when Process is ProcessPageEdit and call is ajax** @param HookEvent $event**/public function hookPagePermissionsPageEditableAjax(HookEvent $event) {if($event->return) return;/** @var Page|RepeaterPage $page */$page = $event->arguments(0);if(!$page instanceof RepeaterPage) {$t = $page->template;if(strpos("$t", "repeater_") === 0) {$this->bd("Page $page ($t) has wrong class ($page->className != $t->pageClass)", __FUNCTION__, true);}return;}$forField = $page->getForField();$n = 0;while($page instanceof RepeaterPage) {$forField = $page->getForField();$page = $page->getForPage();if(++$n > 20) break;}if(!$page || !$page->id || $page instanceof RepeaterPage) {// no owning forPage found$event->return = false;return;}// found the original owning page (forPage)$editable = null;$user = $this->wire()->user;$input = $this->wire()->input;$fieldName = $input->get('field');if($fieldName) {$_fieldName = $fieldName;$fieldName = $this->wire()->sanitizer->fieldName($fieldName);if($fieldName === $_fieldName) {$field = $this->wire()->fields->get($fieldName);} else {$field = null;$editable = false;}} else {$field = $forField;}if($page instanceof User && $field && $field->type instanceof FieldtypeRepeater) {// editing a repeater field in a Userif($user->hasPermission('user-admin')) {$editable = true;} else if($page->id === $user->id) {// user editing themself, repeater field/** @var PagePermissions $pagePermissions */$pagePermissions = $this->wire()->modules->get('PagePermissions');$editable = $pagePermissions->userFieldEditable($field);}}if($editable === null) $editable = $page->editable();$event->return = $editable;}/*** Hook into PageFinder::getQuery** Determines if the query is attempting to directly search a field used by a repeater.* If it is, then it specifically excludes them. This is so that one could use a 'title' field* in both a repeater and elsewhere, and not worry about repeaters themselves appearing in* search results for an admin.** @param HookEvent $event**/public function hookPageFinderGetQuery(HookEvent $event) {/** @var Selectors $selectors */$selectors = $event->arguments[0];/** @var PageFinder $pageFinder */$pageFinder = $event->object;$pageFinderOptions = $pageFinder->getOptions();// determine which fields are used in repeatersif(!isset(self::$fieldsUsedInRepeaters[$this->instanceID])) {$fieldNames = array('title' => 'title'); // title used by admin template (repeater parents)$templates = $this->wire()->templates;$templateIds = array();$allTemplateNames = $templates->getAllValues('name', 'id');$fieldgroups = $this->wire()->fieldgroups;foreach($allTemplateNames as $templateId => $templateName) {if(strpos($templateName, self::templateNamePrefix) !== 0) continue;$templateIds[$templateName] = $templateId;foreach($fieldgroups->getFieldNames($templateName) as /* $fieldId => */ $fieldName) {$fieldNames[$fieldName] = $fieldName;}}self::$fieldsUsedInRepeaters[$this->instanceID] = array_values($fieldNames);self::$templatesUsedByRepeaters[$this->instanceID] = array_values($templateIds);}$fieldsUsedInRepeaters = self::$fieldsUsedInRepeaters[$this->instanceID];$templatesUsedByRepeaters = self::$templatesUsedByRepeaters[$this->instanceID];// did we find a field used by a repeater in the selector?$found = false;// was include=all specified?$includeAll = !empty($pageFinderOptions['findAll']);// if user is guest, then repeater pages will already be excluded (since they don't have view access to them) so no need for extra filterif(!$includeAll && $this->wire()->user->isGuest()) $includeAll = true;// determine if any of the fields used in the selector are also used in a repeater// and set $found and $includeAll as appropriateif(!$includeAll) foreach($selectors as $selector) {$fields = $selector->field;if(!is_array($fields)) $fields = array($fields);foreach($fields as $name) {if(strpos($name, '.')) {/** @noinspection PhpUnusedLocalVariableInspection */list($name, $unused) = explode('.', $name); // field.subfield}// is field name one used by a repeater?if(in_array($name, $fieldsUsedInRepeaters)) $found = true;if($name == 'status' && $selector->operator == '<' && $selector->value == Page::statusMax) {// include=all is the same as status<Page::statusMax, so we look for that here$includeAll = true;} else if(in_array($name, array('parent', 'parent_id', 'template', 'templates_id')) && $selector->operator == '=') {// optimization: if parent, parent_id, template, or templates_id is given, and an equals '=' operator is used,// there's no need to explicitly exclude repeaters since the parent and/or template is specific$includeAll = true;} else if($name == 'templates_id' && $selector->operator == '=' && in_array($selector->value, $templatesUsedByRepeaters)) {// ensure that the repeaters own queries work since they specify a templates_id// note: this is now redundent given the code added directly above this, but kept for clarification$includeAll = true;} else if($name == 'has_parent' && $selector->value != 1 && $selector->operator == '=' && $selector->value != '/') {// if has_parent is specified and is not homepage, no need to exclude results$includeAll = true;}if($includeAll) break;}if($includeAll) break;}// if field is one used by a repeater, and there was no include=all,// then exclude repeaters from appearing in these PageFinder search resultsif($found && !$includeAll) {// for reference: $selectors->add(new SelectorNotEqual('has_parent', $this->repeatersRootPageID));$selectors->add(new SelectorNotEqual('templates_id', $templatesUsedByRepeaters)); // more efficient than has_parent}}/*** This hook is called before ProcessPageEdit::ajaxSave** We modify the HTTP_X_FIELDNAME var to remove the "_repeater123" portion of the variable,* since ProcessPageEdit doesn't know about repeaters.** @param HookEvent $event**/public function hookProcessPageEditAjaxSave(HookEvent $event) {// if this isn't a repeater field we're dealing with, then abortif(!isset($_SERVER['HTTP_X_FIELDNAME'])) return;if(strpos($_SERVER['HTTP_X_FIELDNAME'], '_repeater') === false) return;if(!preg_match('/^(.+)(_repeater(\d+))(?:$|\.)/', $_SERVER['HTTP_X_FIELDNAME'], $matches)) return;$sanitizer = $this->wire()->sanitizer;$fieldName = $sanitizer->fieldName($matches[1]);$repeaterPageID = (int) $matches[3];if($repeaterPageID < 1) return;// make sure the owning page is editable since we'll be replacing the $page param that goes to ajaxSave/** @var Page $ownerPage */$ownerPage = $event->arguments[0];if(!$ownerPage->editable()) return;// make sure it's a valid repeaterPage$repeaterPage = $this->wire()->pages->get($repeaterPageID);if(!$repeaterPage->id) return;// check that the given repeaterPage is actually a repeater component of the ownerPageif($repeaterPage->id != $ownerPage->id && !$this->isRepeaterItemValidOnPage($repeaterPage, $ownerPage)) {$this->error("Repeater item $repeaterPage not valid for owner page $ownerPage");return;}// repopulate the ProcessPageEdit::ajaxSave function's argument to be the repeaterPage rather than the ownerPage$args = $event->arguments;$args[0] = $repeaterPage;$event->arguments = $args;$ajaxFieldName = $_SERVER['HTTP_X_FIELDNAME'];if(strpos($ajaxFieldName, '.')) {// field.subfield combination, i.e. FieldtypeCombo ajax subfieldlist($ajaxFieldName, $ajaxSubfieldName) = explode('.', $ajaxFieldName, 2);$ajaxFieldName = $sanitizer->fieldName($ajaxFieldName);$ajaxSubfieldName = $sanitizer->name($ajaxSubfieldName);$this->ajaxFieldName = $ajaxFieldName;// repopulate the server header to be the fieldName (sans _repeater\d+)$_SERVER['HTTP_X_FIELDNAME'] = "$fieldName.$ajaxSubfieldName";} else {$this->ajaxFieldName = $sanitizer->fieldName($_SERVER['HTTP_X_FIELDNAME']);// repopulate the server header to be the fieldName (sans _repeater\d+)$_SERVER['HTTP_X_FIELDNAME'] = $fieldName;}// save a copy for comparison in our hookPageEditable function$this->ajaxPage = $repeaterPage;// add a hook to allow edit access because some users may not have access// to the repeater pages themselves, and ProcessPageEdit's editable check// prevents them from completing the ajax save. this hook fixes that.$this->addHookAfter('ProcessPageEdit::ajaxEditable', $this, 'hookProcessPageEditAjaxEditable');// ensures that InputfieldFile outputs markup with the proper fieldname, including the repeater_ part$this->addHookBefore('InputfieldFile::renderItem', $this, 'hookInputfieldFileRenderItem');}/*** Is the given repeater item valid on the given owner page?** @param Page $repeaterItem* @param Page $ownerPage* @return null|Field Returns the repeater Field object that is valid, or null if not valid**/protected function isRepeaterItemValidOnPage(Page $repeaterItem, Page $ownerPage) {$hasField = null;$repeaters = array();foreach($ownerPage->fieldgroup as $f) {/** @var Field $f */if(!$f->type instanceof FieldtypeRepeater) continue;$repeaters[$f->name] = $f->name;$grandparent = $this->getRepeaterParent($f);$name = self::repeaterPageNamePrefix . $ownerPage->id;$parent = $grandparent->child("name=$name, include=all");if(!$parent->id) continue;$child = $parent->child("include=all, id=$repeaterItem->id");if($child->id) {// found it, it's valid$hasField = $f;break;}}if($hasField) return $hasField;// check for nested repeaterforeach($repeaters as $name) {$repeaterItems = $ownerPage->get($name);if(!$repeaterItems) continue;if($repeaterItems instanceof PageArray) {foreach($repeaterItems as $nestedOwnerPage) {// perform recursive check$hasField = $this->isRepeaterItemValidOnPage($repeaterItem, $nestedOwnerPage);if($hasField) break;}} else if($repeaterItems instanceof RepeaterPage) {// for single item value (i.e. FieldtypeFieldsetPage)$hasField = $this->isRepeaterItemValidOnPage($repeaterItem, $repeaterItems);} else {// continue;}}return $hasField;}/*** Temporary hook into Page::editable to capture the editable check for the page we swapped into the ajaxSave** Prevents the 'no access' error when non-superuser attempts to perform an ajax save** @param HookEvent $event**/public function hookProcessPageEditAjaxEditable(HookEvent $event) {/** @var Page $page */$page = $event->arguments[0];$fieldName = isset($event->arguments[1]) ? $this->wire()->sanitizer->fieldName($event->arguments[1]) : '';if($page->id && $this->ajaxPage && $this->ajaxPage->id == $page->id) {$event->return = true;}// if a fieldName was specified, double check that it's a valid field in a repeaterif($event->return && $fieldName) {if(!$this->ajaxPage->hasField($fieldName)) $event->return = false;}}/*** Ensure that InputfieldFile outputs markup with the proper fieldname (including the repeater_ part)** @param HookEvent $event**/public function hookInputfieldFileRenderItem(HookEvent $event) {$arguments = $event->arguments;$id = $arguments[1];$id = str_replace($_SERVER['HTTP_X_FIELDNAME'], $this->ajaxFieldName, $id);$arguments[1] = $id;$event->arguments = $arguments;// update id attribute of the Inputfield itself// so that anything in InputfieldFile referring to it's overall id attribute// reflects the actual id attribute of the Inputfield/** @var Inputfield $inputfield */$inputfield = $event->object;$id = $inputfield->attr('id');$id = str_replace($_SERVER['HTTP_X_FIELDNAME'], $this->ajaxFieldName, $id);$inputfield->attr('id', $id);}/*** Delete any repeater pages that are owned by a page that was deleted** @param HookEvent $event**/public function hookPagesDelete(HookEvent $event) {$page = $event->arguments[0];$pages = $this->wire()->pages;foreach($page->template->fieldgroup as $field) {if(!$field->type instanceof FieldtypeRepeater) continue;$fieldParent = $pages->get($field->parent_id);if(!$fieldParent->id) continue;$p = $fieldParent->child('include=all, name=' . self::repeaterPageNamePrefix . $page->id);if($p->id && $this->deleteRepeaterPage($p, $field, true)) {$this->bd("Deleted page $p->path for page $page->path", __FUNCTION__);}}}/*** FieldtypeRepeater instances are only compatible with other FieldtypeRepeater derived classes.** @param Field $field* @return WireArray**/public function ___getCompatibleFieldtypes(Field $field) {$fieldtypes = parent::___getCompatibleFieldtypes($field);foreach($fieldtypes as $type) if(!$type instanceof FieldtypeRepeater) $fieldtypes->remove($type);return $fieldtypes;}/*** Get a blank value of this type, i.e. return a blank PageArray** @param Page $page* @param Field $field* @return PageArray|RepeaterPageArray**/public function getBlankValue(Page $page, Field $field) {$class = $this->getPageArrayClass();$pageArray = $this->wire(new $class($page, $field));$pageArray->setTrackChanges(true);return $pageArray;}/*** Returns a unique name for a repeater page** @return string**/public function getUniqueRepeaterPageName() {static $cnt = 0;return str_replace('.', '-', microtime(true)) . '-' . (++$cnt);}/*** Get the class for the Inputfield (template method)** @return string**/protected function getInputfieldClass() {return 'InputfieldRepeater';}/*** Return an InputfieldRepeater, ready to be used** @param Page $page Page being edited* @param Field $field Field that needs an Inputfield* @return Inputfield**/public function getInputfield(Page $page, Field $field) {/** @var InputfieldRepeater $inputfield */$inputfield = $this->wire()->modules->get($this->getInputfieldClass());$inputfield->set('page', $page);$inputfield->set('field', $field);$inputfield->set('repeaterMaxItems', (int) $field->get('repeaterMaxItems'));$inputfield->set('repeaterMinItems', (int) $field->get('repeaterMinItems'));$inputfield->set('repeaterDepth', (int) $field->get('repeaterDepth'));$inputfield->set('repeaterReadyItems', 0); // ready items deprecated$pageArray = $page->getUnformatted($field->name);if(!$pageArray instanceof PageArray) $pageArray = $this->getBlankValue($page, $field);// we want to check that this page actually has the field before creating ready pages// this is just since PW may call getInputfield with a dummyPage (usually homepage) for tests// and we don't want to go on creating readyPages or setting up parent/template where not usedif($page->hasField($field)) {if(!count($pageArray)) {// force the wakeup function to be called since it wouldn't have been for a field that doesn't yet exist$pageArray = $this->wakeupValue($page, $field, null);}}$page->set($field->name, $pageArray);$inputfield->attr('value', $pageArray);return $inputfield;}/*** Get next page ready to be used as a new repeater item, creating it if it doesn't already exist** @param Page $page* @param Field $field* @param PageArray|Page $value* @param array $notIDs Optional Page IDs that should be excluded from the next ready page* @return Page**/public function getNextReadyPage(Page $page, Field $field, $value = null, array $notIDs = array()) {$database = $this->wire()->database;$readyPage = null;if($value) {if($value instanceof Page) $value = array($value);foreach($value as $item) {/** @var Page $item */if($item->hasStatus(Page::statusUnpublished)&& $item->hasStatus(Page::statusHidden)&& $item->id&& substr($item->name, -1) !== 'c' // cloned item&& !in_array($item->id, $notIDs)) {// existing/unused ready item that we will reuse$readyPage = $item;// touch the modified date for existing page to identify it as still current$query = $database->prepare('UPDATE pages SET modified=NOW(), modified_users_id=:user_id WHERE id=:id');$query->bindValue(':id', $readyPage->id, \PDO::PARAM_INT);$query->bindValue(':user_id', $this->wire('user')->id, \PDO::PARAM_INT);$query->execute();break;}}}if(!$readyPage) {$readyPage = $this->getBlankRepeaterPage($page, $field);$readyPage->sort = count($value);$readyPage->save();}$readyPage->setQuietly('_repeater_new', 1);$this->readyPageSaved($readyPage, $page, $field);return $readyPage;}/*** Hook called when a ready page is saved** @param Page $readyPage* @param Page $ownerPage* @param Field $field**/protected function ___readyPageSaved(Page $readyPage, Page $ownerPage, Field $field) {// for hooks only}/*** Returns a blank page ready for use as a repeater** Also ensures that the parent repeater page exists.* This is public so that the Inputfield can pull from it too.** @param Page $page The page that the repeater field lives on* @param Field $field* @return Page**/public function getBlankRepeaterPage(Page $page, Field $field) {if($this->deletePageField === $field->get('parent_id')) $this->deletePageField = 0;$parent = $this->getRepeaterPageParent($page, $field);$class = $this->getPageClass();/** @var RepeaterPage $readyPage */$readyPage = $this->wire(new $class());$readyPage->template = $this->getRepeaterTemplate($field);if($parent->id) $readyPage->parent = $parent;$readyPage->addStatus(Page::statusOn); // request publish for new items by defalt$readyPage->addStatus(Page::statusHidden); // ready page$readyPage->addStatus(Page::statusUnpublished); // ready page$readyPage->name = $this->getUniqueRepeaterPageName();$readyPage->setForPage($page);$readyPage->setForField($field);return $readyPage;}/*** Given a raw value (value as stored in DB), return the value as it would appear in a Page object** Something to note is that this wakeup function is different than most in that the $value it is given* is just an array like array('data' => 123, 'parent_id' => 456) -- it doesn't actually contain any of the* repeater page data other than saying how many there are and the parent where they are stored. So this* wakeup function can technically do it's job without even having the $value, unlike most other fieldtypes.** @param Page $page* @param Field $field* @param array $value* @return PageArray $value**/public function ___wakeupValue(Page $page, Field $field, $value) {$field_parent_id = $field->get('parent_id');$template_id = $field->get('template_id');// $outputFormatting = $page->outputFormatting();// if it's already in the target format, leave itif(!is_array($value) && $value instanceof PageArray) return $value;// if this field has no parent set, just return a blank pageArrayif(!$field_parent_id) return $this->getBlankValue($page, $field);if(is_array($value) && !empty($value['parent_id'])) {// this is what we get if there was a record in the DB and the parent has been setup$parent_id = (int) $value['parent_id'];} else if(empty($value['data']) && empty($value['parent_id']) && $this->useLazyParents($field)) {// no record in the DB yet and parent will not be created till needed$parent = $this->getRepeaterPageParent($page, $field, false);$parent_id = $parent->id;} else {// no record in the DB yet, so setup the parent if it isn't already$parent = $this->getRepeaterPageParent($page, $field);$parent_id = $parent->id;}// get the template_id used by the repeater pagesif(!$template_id) $template_id = $this->getRepeaterTemplate($field)->id;// if we were unable to determine a parent for some reason, then just return a blank pageArrayif(!$parent_id || !$template_id) {$pageArray = $this->getBlankValue($page, $field);return $pageArray;}// build the selector: find pages with our parent// $selector = "parent_id=$parent_id, templates_id=$template_id, sort=sort, check_access=0";$selector = "parent_id=$parent_id, templates_id=$template_id, sort=sort, include=all";/*if($outputFormatting) {// if an unpublished page is being previewed, let unpublished items be shown (ready items will be removed afterwards)if($page->hasStatus(Page::statusUnpublished) && $page->editable($field->name)) $selector .= ", include=all";} else {// if the page is an edit state, then make it include the hidden/unpublished ready pagesif($page->editable($field->name)) $selector .= ", include=all";}*/$template = $this->wire()->templates->get((int) $template_id);$pageArrayClass = $this->getPageArrayClass();/** @var RepeaterPageArray $pageArray */$pageArray = $this->wire(new $pageArrayClass($page, $field));// load the repeater pages$options = array('cache' => false,'caller' => $this->className() . '::wakeupValue','loadOptions' => array('cache' => false,'parent_id' => $parent_id,'template' => $template,'pageClass' => $this->getPageClass(),'pageArray' => $pageArray,));$pageArray = $this->wire()->pages->find($selector, $options);$pageArray->resetTrackChanges(true);return $pageArray;}/*** Given an 'awake' value, as set by wakeupValue, convert the value back to a basic type for storage in DB.** In this case, the sleepValue doesn't represent the actual value as they are stored in pages.** @param Page $page* @param Field $field* @param string|int|array|object $value* @return array**/public function ___sleepValue(Page $page, Field $field, $value) {$sleepValue = array();// if value is already an array, then just return itif(is_array($value)) return $sleepValue;// if $value isn't a PageArray, then abortif(!$value instanceof PageArray) return array();/** @var RepeaterPageArray $value */$numPublished = 0;$numTotal = 0;$ids = array();// iterate through the array and count how many published we haveforeach($value as $p) {$numTotal++;if(!$p->id || $p->isHidden() || $p->isUnpublished()) continue;$ids[] = $p->id;$numPublished++;}if(!$numTotal && $this->useLazyParents($field)) {$parent = $this->getRepeaterPageParent($page, $field, false);$sleepValue = array('data' => '','count' => 0,'parent_id' => $parent->id);} else {// our sleepValue is simply just the total number of repeater pages// a cache of page IDs in 'data' (for export portability)// and a quick reference to the parent where they are contained$parent = $this->getRepeaterPageParent($page, $field);$sleepValue = array('data' => implode(',', $ids),'count' => $numPublished,'parent_id' => $parent->id);}return $sleepValue;}/*** Export repeater value** @param Page $page* @param Field $field* @param RepeaterPageArray $value* @param array $options* - `minimal` (bool): Export a minimal array of just fields and values indexed by repeater page name (default=false)* @return array**/public function ___exportValue(Page $page, Field $field, $value, array $options = array()) {$a = array();if(!WireArray::iterable($value)) return $a;if(!empty($options['minimal']) || !empty($options['FieldtypeRepeater']['minimal'])) {// minimal export option includes only fields dataforeach($value as $p) {/** @var Page $p */if($p->isUnpublished()) continue;$v = array();foreach($p->template->fieldgroup as $f) {/** @var Field $f */if(!$p->hasField($f)) continue;$fieldtype = $f->type; /** @var Fieldtype $fieldtype */$v[$f->name] = $fieldtype->exportValue($p, $f, $p->getUnformatted($f->name), $options);}$a[$p->name] = $v;}} else {// regular export/** @var PagesExportImport $exporter */$exporter = $this->wire(new PagesExportImport());$a = $exporter->pagesToArray($value, $options);}return $a;}/*** Import repeater value previously exported by exportValue()** @param Page $page* @param Field $field* @param array $value* @param array $options* @return bool|PageArray* @throws WireException**/public function ___importValue(Page $page, Field $field, $value, array $options = array()) {if(empty($value['type']) || $value['type'] != 'ProcessWire:PageArray') {throw new WireException("$field->name: Invalid repeater importValue() \$value argument");}if(!$page->id) {$page->trackChange($field->name);throw new WireException("$field->name: Repeater will import after page is created");}$repeaterParent = $this->getRepeaterPageParent($page, $field);$repeaterTemplate = $this->getRepeaterTemplate($field);$repeaterPageClass = $this->getPageClass();$repeaterPageArrayClass = $this->getPageArrayClass();$parentPath = $repeaterParent->path();$commit = isset($options['commit']) ? (bool) $options['commit'] : true;$messages = array();$numAdded = 0;$changesByField = array();$numUpdated = 0;$numDeleted = 0;$itemsAdded = array();$itemsDeleted = array();$importItemNames = array();$existingValue = $page->get($field->name);if(!$existingValue instanceof PageArray) { // i.e. FieldsetPage$existingValue = $existingValue->id ? array($existingValue) : array();}$pages = $this->wire()->pages;// update paths for localforeach($value['pages'] as $key => $item) {$name = $item['settings']['name'];if(strpos($name, self::repeaterPageNamePrefix) === 0 && count($value['pages']) == 1) {$name = self::repeaterPageNamePrefix . $page->id; // i.e. FieldsetPage$value['pages'][$key]['settings']['name'] = $name;}$path = $parentPath . $name . '/';$importItemNames[$name] = $name;$value['pages'][$key]['path'] = $path;$p = $pages->get($path);if($p->id) continue; // already exists// from this point forward, it is assumed we are creating a new repeater item$numAdded++;$page->trackChange($field->name);if($commit) {// create new repeater item, ready to be populated/** @var RepeaterPage $p */$p = $this->wire(new $repeaterPageClass());if($repeaterParent->id) $p->parent = $repeaterParent;$p->template = $repeaterTemplate;$p->name = $name;$p->setForPage($page);$p->setForField($field);$p->save();$itemsAdded[$p->id] = $p;if($p->name != $name) $importItemNames[$p->name] = $p->name;}}if($page->get('_importType') == 'update') {foreach($existingValue as $p) {if(!isset($importItemNames[$p->name])) {$itemsDeleted[] = $p;$numDeleted++;}}}/** @var RepeaterPageArray $pageArray */$pageArray = $this->wire(new $repeaterPageArrayClass($page, $field));$importOptions = array('commit' => $commit,'create' => true,'update' => true,'delete' => true, // @todo'pageArray' => $pageArray);/** @var PagesExportImport $importer */$importer = $this->wire(new PagesExportImport());$pageArray = $importer->arrayToPages($value, $importOptions);foreach($pageArray as $p) {$changes = $p->get('_importChanges');if(!count($changes)) continue;if(isset($itemsAdded[$p->id]) || !$p->id) continue;$numUpdated++;foreach($changes as $fieldName) {if(!isset($changesByField[$fieldName])) $changesByField[$fieldName] = 0;$changesByField[$fieldName]++;}$this->wire()->notices->move($p, $pageArray, array('prefix' => "$field->name (id=$p->id): "));}if($numDeleted && $commit) {foreach($itemsDeleted as $p) {$pages->delete($p);}}if($numUpdated) {$updateCounts = array();foreach($changesByField as $fieldName => $count) {$updateCounts[] = "$fieldName ($count)";}$messages[] = "$numUpdated page(s) updated – " . implode(', ', $updateCounts);}if($numAdded) $messages[] = "$numAdded new page(s) added";if($numDeleted) $messages[] = "$numDeleted page(s) DELETED";foreach($messages as $message) {$pageArray->message("$field->name: $message");}$pageArray->resetTrackChanges();$totalChanges = $numUpdated + $numAdded + $numDeleted;if(!$totalChanges) {// prevent it from being counted as a change when import code sets the value back to the page$page->setQuietly($field->name, $pageArray);}return $pageArray;}/*** Get associative array of options (name => default) that Fieldtype supports for importValue** #pw-internal** @param Field $field* @return array**/public function getImportValueOptions(Field $field) {$options = parent::getImportValueOptions($field);$options['test'] = true;return $options;}/*** Get information used by selectors for querying this field** @param Field $field* @param array $data* @return array**/public function ___getSelectorInfo(Field $field, array $data = array()) {/** @var FieldtypePage $fieldtype */$fieldtype = $this->wire()->modules->get('FieldtypePage');$info = $fieldtype->getSelectorInfo($field, $data);$info['operators'] = array(); // force it to be non selectable, subfields onlyreturn $info;}/*** Get repeaters root page** @return Page* @since 3.0.188**/public function getRepeatersRootPage() {$pages = $this->wire()->pages;$page = $pages->get((int) $this->repeatersRootPageID);if($page->id && $page->name === self::repeatersRootPageName) return $page;$page = $pages->get($this->wire()->config->adminRootPageID)->child('name=repeaters, include=all');return $page;}/*** Return the parent used by the repeater pages for the given Page and Field** i.e. /processwire/repeaters/for-field-12/for-page-123/** @param Page $page* @param Field $field* @param bool $create Create if not exists? (default=true) 3.0.188+* @return Page|NullPage**/public function getRepeaterPageParent(Page $page, Field $field, $create = true) {$repeaterParent = $this->getRepeaterParent($field);$parentName = self::repeaterPageNamePrefix . $page->id; // for-page-123$parent = $repeaterParent->child("name=$parentName, include=all");if($parent->id || !$create) return $parent;$parent = $this->wire()->pages->newPage($repeaterParent->template);$parent->parent = $repeaterParent;$parent->name = $parentName;$parent->title = $page->name;$parent->addStatus(Page::statusSystem);// exit early if a field is in the process of being deleted// so that a repeater page parent doesn't get automatically re-createdif($this->deletePageField === $field->get('parent_id')) return $parent;$parent->save();$this->bd("Created '$field' page parent: $parent->path", __FUNCTION__);return $parent;}/*** Return the repeater parent used by $field, i.e. /processwire/repeaters/for-field-123/** Auto generate a repeater parent page named 'for-field-[id]', if it doesn't already exist** @param Field $field* @return Page* @throws WireException**/public function getRepeaterParent(Field $field) {$pages = $this->wire()->pages;$parentID = (int) $field->get('parent_id');if($parentID) {$parent = $pages->get($parentID);if($parent->id) {if($parent->title != $field->name) $parent->setAndSave('title', $field->name);return $parent;}}$repeatersRootPage = $this->getRepeatersRootPage();$parentName = self::fieldPageNamePrefix . $field->id; // for-field-123// we call this just to ensure it exists, so template is created if it doesn't exist yetif(!$field->get('template_id')) $this->getRepeaterTemplate($field);$parent = $repeatersRootPage->child("name=$parentName, include=all");if(!$parent->id) {$parent = $pages->newPage($repeatersRootPage->template);$parent->parent = $repeatersRootPage;$parent->name = $parentName;$parent->title = $field->name;$parent->addStatus(Page::statusSystem);$parent->save();$this->bd("Created '$field' parent: $parent->path", __FUNCTION__);}if($parent->id) {if(!$field->get('parent_id')) {// parent_id setting not yet in field$field->set('parent_id', $parent->id);$field->save();}} else {throw new WireException("Unable to create parent {$repeatersRootPage->path}$parentName");}return $parent;}/*** Update repeater template and fieldgroup to have same name as field** @param Template $template Template having old name* @param string $name New name for template**/protected function updateRepeaterTemplateName(Template $template, $name) {if($template->name != $name && !$this->wire()->templates->get($name)) {$this->bd("Renamed repeater template from '$template->name' to '$name'", __FUNCTION__);$flags = $template->flags;$template->flags = Template::flagSystemOverride; // required before flags=0$template->flags = 0;$template->save();$template->name = $name;$template->flags = $flags;$template->save();}if($template->fieldgroup && $template->fieldgroup->name != $name && !$this->wire()->fieldgroups->get($name)) {$template->fieldgroup->name = $name;$template->fieldgroup->save();}}/*** Return the repeater template used by Field, i.e. repeater_name** Auto generate a repeater template, if it doesn't already exist.** @param Field $field* @return Template* @throws WireException**/protected function getRepeaterTemplate(Field $field) {$templates = $this->wire()->templates;$fieldgroups = $this->wire()->fieldgroups;$template = null;$templateID = (int) $field->get('template_id');$templateName = self::templateNamePrefix . $field->name;if($templateID) {$template = $templates->get($templateID);if($template && $template->name !== $templateName) {// repeater has been renamed, update the template and fieldgroup names$this->updateRepeaterTemplateName($template, $templateName);}}// if template already exists, return it nowif($template) return $template;// make sure the template name isn't already in use, make a unique one if it is$n = 0;while($templates->get($templateName) || $fieldgroups->get($templateName)) {$templateName = self::templateNamePrefix . $field->name . (++$n);}// create the fieldgroup$fieldgroup = $this->wire(new Fieldgroup()); /** @var Fieldgroup $fieldgroup */$fieldgroup->name = $templateName;$fieldgroup->save();if(!$fieldgroup->id) throw new WireException("Unable to create repeater fieldgroup: $templateName");// create the template$template = $this->wire(new Template()); /** @var Template $template */$template->name = $templateName;$template->fieldgroup = $fieldgroup;$this->populateRepeaterTemplateSettings($template);$template->save();if(!$template->id) throw new WireException("Unable to create template: $templateName");// save the template_id setting to the field$field->set('template_id', $template->id);$field->save();$this->bd("Created '$field' template: $template", __FUNCTION__);return $template;}/*** Populate the settings for a newly created repeater template** @param Template $template**/protected function populateRepeaterTemplateSettings(Template $template) {$template->flags = Template::flagSystem;$template->noChildren = 1;$template->noParents = 1; // prevents users from creating pages with this template, but not us$template->noGlobal = 1;}/*** Handles the sanitization and convertion to PageArray value** @param Page $page* @param Field $field* @param mixed $value* @return PageArray|RepeaterPageArray**/public function sanitizeValue(Page $page, Field $field, $value) {// if they are setting it to a PageArray, then we'll take itif($value instanceof PageArray) return $value;// otherwise, lets get the current value so we can add to it or return it$pageArray = $page->get($field->name); /** @var RepeaterPageArray $pageArray */// if no value was provided, then return the existing value already in the pageif(!$value) return $pageArray;// if it's a string, see if we can convert it to a Page or PageArrayif(is_string($value)) $value = $this->sanitizeValueString($page, $field, $value);// if it's a Page, and not NullPage, add it to the existing PageArrayif($value instanceof Page) {$pageArray->add($value);return $pageArray;}// if it's a new PageArray, combine it with the existing PageArrayif($value instanceof PageArray) {foreach($value as $pg) {if(!$pg->id) continue;$pageArray->add($pg);}return $pageArray;}if(!is_array($value)) $value = array($value);foreach($value as $p) $pageArray->add($p);return $pageArray;}/*** Given a string value return a Page or PageArray** @param Page $page* @param Field $field* @param string $value* @return Page|PageArray**/public function sanitizeValueString(Page $page, Field $field, $value) {$pages = $this->wire()->pages;$result = false;if(ctype_digit("$value")) {// single page ID$result = $pages->get((int) $value);} else if(strpos($value, ',')) {// csv string of page IDs$value = explode(',', $value);$result = array();foreach($value as $v) {$v = (int) $v;if($v) $result[] = $v;}// @todo confirm this is the parent_id we want (field parent_id vs page parent_id)$result = $pages->getById($result, $this->getRepeaterTemplate($field), $field->get('parent_id'));} else if(Selectors::stringHasOperator($value)) {// selector// @todo confirm this is the parent_id we want (field parent_id vs page parent_id)$parentID = $field->get('parent_id');$templateID = $field->get('template_id');$result = $pages->find("parent_id=$parentID, templates_id=$templateID, $value");} else if(strlen($value) && $value[0] == '/') {// path$result = $pages->get($value);}return $result;}/*** Perform output formatting on the value delivered to the API** This method is only used when $page->outputFormatting is true.** @param Page $page* @param Field $field* @param PageArray $value* @return PageArray**/public function ___formatValue(Page $page, Field $field, $value) {$maxItems = (int) $field->get('repeaterMaxItems');if(!$value instanceof PageArray) $value = $this->getBlankValue($page, $field);// used as a clone if a formatted version of $value is different from non-formatted$formatted = null;$cnt = 0;// remove unpublished and ready items that shouldn't be hereforeach($value as $p) {$cnt++;if($p->isHidden() || $p->isUnpublished() || ($maxItems && $cnt > $maxItems)) {if(is_null($formatted)) $formatted = clone $value;/** @var Page $formatted */$trackChanges = $formatted->trackChanges();$formatted->setTrackChanges(false);$formatted->remove($p);$formatted->setTrackChanges($trackChanges);$cnt--;}}return is_null($formatted) ? $value : $formatted;}/*** Update a DatabaseQuerySelect object to match a Page** @param PageFinderDatabaseQuerySelect $query* @param string $table* @param string $subfield* @param string $operator* @param string $value* @return PageFinderDatabaseQuerySelect* @throws WireException**/public function getMatchQuery($query, $table, $subfield, $operator, $value) {$field = $query->field;if($subfield == 'count') {$value = (int) $value;if( ($operator == '=' && $value == 0) ||(in_array($operator, array('<', '<=')) && $value > -1) ||($operator == '!=' && $value) || ($operator === '>=' && $value == 0)) {$templateIds = array();foreach($field->getTemplates() as $t) {/** @var Template $t */$templateIds[] = (int) $t->id;}if(count($templateIds)) {$templateIds = implode(',', $templateIds);$fieldTable = $field->getTable();$joinTable = "cnt_repeater_$table";$parentQuery = $query->parentQuery;$parentQuery->leftjoin("$fieldTable AS $joinTable " ."ON pages.templates_id IN($templateIds) " ."AND $joinTable.pages_id=pages.id ");$parentQuery->where("(($joinTable.count{$operator}$value OR $joinTable.pages_id IS NULL) " ."AND pages.templates_id IN($templateIds))");} else {$query->where('1>2');}} else {$query->where("($table.count{$operator}$value)");}} else if($subfield == 'parent_id' || $subfield == 'parent') {$subfield = 'parent_id';if(is_object($value)) $value = (string) $value;$value = (int) $value;$query->where("($table.$subfield{$operator}$value)");} else if($subfield == 'data' || $subfield == 'id' || !$subfield) {// support matching of IDs via word matching fulltext indexif(ctype_digit("$value") && !empty($value)) {if($subfield === 'id') $subfield = 'data';if($operator === '=') {$operator = '~=';} else if($operator === '!=') {// @todo specify NOT$operator = '~=';}}if(in_array($operator, array('*=', '~=', '^=', '$=', '%='))) {/** @var DatabaseQuerySelectFulltext $ft */$ft = $this->wire(new DatabaseQuerySelectFulltext($query));$ft->match($table, $subfield, $operator, $value);} else if(empty($value)) {// empty/0 valueif($operator === '=' && $subfield === 'id') {$query->where('1>2'); // force non-match} else {// match where count is 0$query->where("$table.count{$operator}0");}} else {// match /path/to/page or other, not implemented}} else {$f = $this->wire()->fields->get($subfield);if(!$f) return $query; // unknown subfield// match fields from the repeater template// perform a separate find() operation for the subfield/** @var PageFinder $pageFinder */$pageFinder = $this->wire(new PageFinder());$value = $this->wire()->sanitizer->selectorValue($value);$templateID = $field->get('template_id');/** @var Selectors $selectors */$selectors = $this->wire(new Selectors("templates_id=$templateID, check_access=0, $f->name$operator$value"));$matches = $pageFinder->find($selectors);// use the IDs found from the separate find() as our getMatchQueryif(count($matches)) {$ids = array();foreach($matches as $match) {$parentID = (int) $match['parent_id'];$ids[$parentID] = $parentID;}$query->where("$table.parent_id IN(" . implode(',', $ids) . ")");} else {$query->where("1>2"); // force a non-match}}return $query;}/*** Return the database schema in predefined format** @param Field $field* @return array**/public function getDatabaseSchema(Field $field) {$schema = parent::getDatabaseSchema($field);// fields$schema['data'] = 'text NOT NULL';$schema['count'] = 'int NOT NULL';$schema['parent_id'] = 'int NOT NULL';// indexes$schema['keys']['data'] = 'FULLTEXT KEY `data` (`data`)'; // just a cache of CSV page IDs for portability$schema['keys']['data_exact'] = 'KEY `data_exact` (`data`(1))'; // just for checking if the field has a value$schema['keys']['count'] = 'KEY `count` (`count`, `pages_id`)';$schema['keys']['parent_id'] = 'KEY parent_id (`parent_id`, `pages_id`)';// indicate that this schema does not hold all data managed by this fieldtype$schema['xtra']['all'] = false;return $schema;}/*** Save the given field from page** @param Page $page Page object to save.* @param Field $field Field to retrieve from the page.* @return bool True on success, false on DB save failure.**/public function ___savePageField(Page $page, Field $field) {if(!$page->id || !$field->id) return false;$pages = $this->wire()->pages;$value = $page->get($field->name);if($value instanceof RepeaterPage) {// for FieldsetPage compatibility$pageArrayClass = $this->getPageArrayClass();/** @var RepeaterPageArray $pageArray */$pageArray = $this->wire(new $pageArrayClass($page, $field));$pageArray->add($value);$pageArray->resetTrackChanges();$value = $pageArray;}// pages that will be saved$savePages = array();// options to pass to save() or clone()$saveOptions = array('uncacheAll' => false);// pages that will be deleted$deletePages = $value->getItemsRemoved();$repeaterParent = null;$parent_id = 0;$template_id = $field->get('template_id');$numTotal = count($value);// iterate through each page in the pageArray value// and determine which need to be savedforeach($value as $p) {/** @var Page|RepeaterPage $p */if($p->template->id != $template_id) {$value->remove($p);$this->bd("Removed invalid template ({$p->template->name}) page {$p->path} from field $field", __FUNCTION__);continue;}if(!$repeaterParent) $repeaterParent = $this->getRepeaterPageParent($page, $field);if(!$parent_id) $parent_id = $repeaterParent->id;if($p->parent->id != $parent_id) {// clone the individual repeater pages$value->remove($p);$p = $pages->clone($p, $repeaterParent, false, $saveOptions);$value->add($p);$this->bd("Cloned to {$p->path} from field $field", __FUNCTION__);continue;}if($p->isNew() && !$p->name && !$p->title) {// if we've got a new repeater page without a name or title// then it's not going to save because it has no way of generating a name// so we will generate one for it$p->name = $this->getUniqueRepeaterPageName();}if($p->isChanged() || $p->isNew()) {// if the page has changed or is new, then we will queue it to be saved$savePages[] = $p;} else if($p->id && $p->isUnpublished() && !$p->isHidden()) {// if the page has an ID, but is still unpublished, though not hidden, then we queue it to be saved (and published)$savePages[] = $p;}}// iterate the pages that had changes and need to be savedforeach($savePages as $p) {/** @var Page|RepeaterPage $p */if($p->id) {// existing page$isHidden = $p->isHidden();$isUnpublished = $p->isUnpublished();$isOn = $p->hasStatus(Page::statusOn);$isProcessed = $p->get('_repeater_processed') === true;$hasErrors = $p->get('_repeater_errors') ? true : false;if($isHidden && $isUnpublished) continue; // this is a 'ready' page, we can ignore$changes = implode(', ', $p->getChanges());$this->bd("Saved '$field' page: {$p->path} " . ($changes ? "($changes)" : ''), __FUNCTION__);if($isUnpublished && $isOn && $isProcessed && !$hasErrors) {// publish requested and allowed$p->removeStatus(Page::statusUnpublished);}} else {$this->bd("Added new '$field' page", __FUNCTION__);}// save the repeater page$pages->save($p, $saveOptions);}// iterate through the pages that were removedforeach($deletePages as $p) {/** @var Page|RepeaterPage $p */// if the deleted value is still present in the pageArray, then don't delete itif($value->has($p)) continue;// $this->message("Deleted Repeater", Notice::debug);// delete the repeater page$pages->delete($p, $saveOptions);}$result = parent::___savePageField($page, $field);// ensure that any of our cloned page replacements (removes) don't get recorded any follow-up saves$value->resetTrackChanges();if(!$numTotal && $this->useLazyParents($field)) {// delete repeater page parent if it has no items below itif(!$repeaterParent) $repeaterParent = $this->getRepeaterPageParent($page, $field, false);if($repeaterParent && $repeaterParent->id) {$numChildren = $pages->count("parent_id=$repeaterParent->id, include=all");if(!$numChildren) {$this->bd("Deleted 0-children repeater parent $repeaterParent->path", __FUNCTION__);$this->deleteRepeaterPage($repeaterParent, $field, true);}}}return $result;}/*** Delete the given field, which implies: drop the table $field->table** This should only be called by the Fields class since fieldgroups_fields lookup entries must be deleted before this method is called.** With the repeater, we must delete the associated fieldgroup, template and parent as well** @param Field $field Field object* @return bool True on success, false on DB delete failure.**/public function ___deleteField(Field $field) {$pages = $this->wire()->pages;$templates = $this->wire()->templates;$template = $templates->get((int) $field->get('template_id'));$parent = $pages->get((int) $field->get('parent_id'));// delete the pages used by this field// check that the parent really is still in our repeaters structure before deleting anythingif($parent->id && $parent->parent_id == $this->repeatersRootPageID) {$parentPath = $parent->path;// remove system status from repeater field parent$parent->addStatus(Page::statusSystemOverride);$parent->removeStatus(Page::statusSystem);// remove system status from repeater page parentsforeach($parent->children as $child) {$child->addStatus(Page::statusSystemOverride);$child->removeStatus(Page::statusSystem);}// resursively delete the field parent and everything below it$pages->delete($parent, true);$this->bd("Deleted '$field' parent: $parentPath", __FUNCTION__);}// delete the template used by this field// check that the template still has system flag before deleting itif($template && ($template->flags & Template::flagSystem)) {$templateName = $template->name;// remove system flag from the template$template->flags = Template::flagSystemOverride; // required before flags=0$template->flags = 0;// delete the template$templates->delete($template);// delete the fieldgroup$fieldgroups = $this->wire()->fieldgroups;$fieldgroup = $fieldgroups->get($templateName);if($fieldgroup) $fieldgroups->delete($fieldgroup);$this->bd("Deleted '$field' template: $templateName", __FUNCTION__);}return parent::___deleteField($field);}/*** Delete the given Field from the given Page** @param Page $page* @param Field $field Field object* @return bool True on success, false on DB delete failure.**/public function ___deletePageField(Page $page, Field $field) {$pages = $this->wire()->pages;$result = parent::___deletePageField($page, $field);$this->deletePageField = $field->get('parent_id');$fieldParent = $pages->get((int) $field->get('parent_id'));// confirm that this field parent page is still part of the pages we manageif($fieldParent->parent_id == $this->repeatersRootPageID) {// locate the repeater page parent$parentName = self::repeaterPageNamePrefix . $page->id;$parent = $fieldParent->child("name=$parentName, include=all");if($parent->id) {// remove system status from repeater page parent$parent->addStatus(Page::statusSystemOverride);$parent->removeStatus(Page::statusSystem);$this->bd("Deleted $parent->path", __FUNCTION__);// delete the repeater page parent and all the repeater pages in it$pages->delete($parent, true);}}return $result;}/*** Move this field’s data from one page to another.** #pw-group-saving** @param Page $src Source Page* @param Page $dst Destination Page* @param Field $field* @return bool**/public function ___replacePageField(Page $src, Page $dst, Field $field) {$pages = $this->wire()->pages;$srcParentName = self::repeaterPageNamePrefix . $src->id;$dstParentName = self::repeaterPageNamePrefix . $dst->id;$parentId = (int) $field->get('parent_id');if(!parent::___replacePageField($src, $dst, $field)) return false;$srcParent = $pages->get("parent_id=$parentId, name=$srcParentName");$dstParent = $pages->get("parent_id=$parentId, name=$dstParentName");if($dstParent->id) $pages->delete($dstParent, true);$srcParent->name = $dstParentName;$srcParent->save();return true;}/*** Create a cloned copy of Field** @param Field $field* @throws WireException**/public function ___cloneField(Field $field) {throw new WireException($this->className() . " does not currently support field cloning.");/*$field = parent::___cloneField($field);$field->parent_id = null;$field->template_id = null;*/}/*** EXPORT AND IMPORT **********************************************************//*** Export configuration values for external consumption** Use this method to externalize any config values when necessary.* For example, internal IDs should be converted to GUIDs where possible.* Most Fieldtype modules can use the default implementation already provided here.** #pw-group-configuration** @param Field $field* @param array $data* @return array**/public function ___exportConfigData(Field $field, array $data) {$data = parent::___exportConfigData($field, $data);$template = $this->wire()->templates->get((int) $data['template_id']);$data['template_id'] = 0;$data['parent_id'] = 0;$data['repeaterFields'] = array();$data['fieldContexts'] = array();$a = $field->get('repeaterFields');if(!is_array($a)) $a = array();foreach($a as $fid) {$f = $this->wire()->fields->get((int) $fid);if(!$f) continue;$data['repeaterFields'][] = $f->name;$data['fieldContexts'][$f->name] = $template->fieldgroup->getFieldContextArray($f->id);}return $data;}/*** Convert an array of exported data to a format that will be understood internally** This is the opposite of the exportConfigData() method.* Most modules can use the default implementation provided here.** #pw-group-configuration** @param Field $field* @param array $data* @return array Data as given and modified as needed. Also included is $data[errors], an associative array* indexed by property name containing errors that occurred during import of config data.**/public function ___importConfigData(Field $field, array $data) {if(!$field->type instanceof FieldtypeRepeater) return $data;$fields = $this->wire()->fields;$errors = array();$repeaterFields = array();$saveFieldgroup = false;$saveFieldgroupContext = false;$template = $field->id ? $this->getRepeaterTemplate($field) : null;if(!empty($data['repeaterFields'])) {foreach($data['repeaterFields'] as $name) {$f = $fields->get($name);if(!$f instanceof Field) {$errors[] = "Unable to locate field to add to repeater: $name";continue;}$repeaterFields[] = $f->id;}$data['repeaterFields'] = $repeaterFields;}if($template && !empty($data['fieldContexts'])) {foreach($data['fieldContexts'] as $name => $contextData) {$f = $fields->get($name);if(!$f instanceof Field) continue;if($template->fieldgroup->hasField($f)) {$f = $template->fieldgroup->getFieldContext($f->name);}$template->fieldgroup->add($f);$saveFieldgroup = true;if(!empty($contextData)) {$template->fieldgroup->setFieldContextArray($f->id, $contextData);$saveFieldgroupContext = true;}}}if($template) {if($saveFieldgroupContext) {$template->fieldgroup->saveContext();}if($saveFieldgroup) {$template->fieldgroup->save();}}unset($data['fieldContexts']);$data = parent::___importConfigData($field, $data);if(count($errors)) {$data['errors'] = array_merge($data['errors'], array('repeaterFields' => $errors));}return $data;}/*** TOOLS ********************************************************************//*** Count or delete old ready pages that are just taking up space** @param Field $field* @param bool $delete Specify true to delete the old ready pages* @param int $secondsOld Number of seconds old that the page has to be to be considered "old" (default=259200 or 3 days)* @return int Count of old ready pages, or if $delete===true, then number that was deleted**/public function countOldReadyPages(Field $field, $delete = false, $secondsOld = 259200) {if(!$field->type instanceof FieldtypeRepeater) return 0;$cnt = 0;$template = $this->getRepeaterTemplate($field);$parent = $this->getRepeaterParent($field);if(!$template || !$parent) return 0;$status = Page::statusHidden + Page::statusUnpublished;$modified = time() - $secondsOld;$selector = "has_parent=$parent, template=$template, status>=$status, modified<=$modified, include=all";if($delete) {$items = $this->wire()->pages->find($selector);foreach($items as $item) {try {if($this->deleteRepeaterPage($item, $field)) $cnt++;} catch(\Exception $e) {$this->error("Error deleting old repeater item $item->path - " . $e->getMessage());}}} else {$cnt = $this->wire()->pages->count($selector);}return $cnt;}/*** Find unnecessary parent pages that may be deleted** @param Field $field* @param array $options* @return PageArray**/public function findUnnecessaryParents(Field $field, array $options = array()) {$defaults = array('useLazyParents' => $this->useLazyParents($field),'limit' => 500,);$options = array_merge($defaults, $options);$database = $this->wire()->database;$forFieldParent = $this->getRepeaterParent($field); // for-field-123$unnecessaryParents = new PageArray();foreach($forFieldParent->children() as $forPageParent) {$name = $forPageParent->name; // for-page-456if(strpos($name, self::repeaterPageNamePrefix) !== 0) continue;list(, $forPageId) = explode(self::repeaterPageNamePrefix, $name, 2);$query = $database->prepare('SELECT COUNT(*) FROM pages WHERE id=:id');$query->bindValue(':id', (int) $forPageId, \PDO::PARAM_INT);$query->execute();$exists = (int) $query->fetchColumn();$query->closeCursor();if(!$exists || ($options['useLazyParents'] && !$forPageParent->numChildren)) {$unnecessaryParents->add($forPageParent);if($options['limit'] && $unnecessaryParents->count() >= $options['limit']) break;}}return $unnecessaryParents;}/*** Delete a repeater page, removing system statuses first** This is able to delete the following types of pages:** - repeaters/for-field-123/* - repeaters/for-field-123/for-page-456/* - repeaters/for-field-123/for-page-456/repeater-item/* - repeaters/for-field-123/for-page-456/repeater-item/something-else/** @param Page $page* @param Field $field Optionally limit to given field or null if not appliable* @param bool $recursive Descend to children?* @return int Returns count of pages deleted, or 0 if delete not allowed* @since 3.0.188**/public function deleteRepeaterPage(Page $page, Field $field = null, $recursive = false) {static $level = 0;$numDeleted = 0;if(!$page->id) return 0;$fieldPageName = self::fieldPageNamePrefix . ($field ? $field->id : '');if(strpos($page->path, '/' . self::repeatersRootPageName . '/') === false) {$this->bd("Cannot delete $page->path because not in repeaters path", __FUNCTION__, true);return 0;}if($field && strpos($page->path, "/$fieldPageName/") === false) {$this->bd("Cannot delete $page->path because not within /$fieldPageName/", __FUNCTION__, true);return 0;}if(strpos($page->name, self::fieldPageNamePrefix) === 0) {// repeater for-field parentif($field && $page->name != $fieldPageName) return 0;} else if(strpos($page->name, self::repeaterPageNamePrefix) === 0) {// repeater for-page parent} else if(strpos($page->template->name, self::templateNamePrefix) === 0) {// repeater item} else if(strpos($page->path, '/' . self::repeaterPageNamePrefix)) {// something with /for-page-123 in the path// child of a repeater item (PageTable or something else?)} else {// some other page, not allowed to delete$this->bd("Not allowed to delete $page->path", __FUNCTION__, true);return 0;}$numChildren = $page->numChildren;if($numChildren && !$recursive) {$this->bd("Cannot delete $page->path because has children", __FUNCTION__, true);return 0;}if($numChildren) {$level++;foreach($page->children('include=all') as $p) {$numDeleted += $this->deleteRepeaterPage($p, $field, $recursive);}$level--;}// remove system statusesif($page->hasStatus(Page::statusSystem) || $page->hasStatus(Page::statusSystemID)) {$page->addStatus(Page::statusSystemOverride);$page->removeStatus(Page::statusSystem);$page->removeStatus(Page::statusSystemID);}if($this->wire()->pages->delete($page, $recursive)) $numDeleted++;return $numDeleted;}/*** @param mixed $msg* @param string|bool $func* @param bool $error**/protected function bd($msg, $func = '', $error = false) {if(!$this->wire()->config->debug || !class_exists('\\TD')) return;if(is_bool($func)) list($error, $func) = array($func, '');if(!self::devMode && !$error) return;call_user_func_array('\\TD::barDump', array($msg, $this->className() . "::$func"));}/*** CONFIG **************************************************************************//*** @var FieldtypeRepeaterConfigHelper|null**/protected $repeaterConfigHelper = null;/*** Use lazy parents mode?** @param RepeaterField|Field $field* @return bool**/public function useLazyParents(Field $field) {/** @var FieldtypeRepeater $fieldtype */if(strpos($this->className(), 'Fieldset')) return false;return ((int) $field->get('lazyParents')) > 0;}/*** @param Field $field* @return FieldtypeRepeaterConfigHelper* @since 3.0.188**/public function getRepeaterConfigHelper(Field $field) {if($this->repeaterConfigHelper && $this->repeaterConfigHelper->getField()->id == $field->id) {return $this->repeaterConfigHelper;}require_once(__DIR__ . '/config.php');$this->repeaterConfigHelper = new FieldtypeRepeaterConfigHelper($field);$this->wire($this->repeaterConfigHelper);return $this->repeaterConfigHelper;}/*** Return configuration fields definable for each FieldtypePage** @param Field $field* @return InputfieldWrapper**/public function ___getConfigInputfields(Field $field) {$inputfields = parent::___getConfigInputfields($field);$template = $this->getRepeaterTemplate($field);$parent = $this->getRepeaterParent($field);if($this->wire()->input->post('repeaterFields')) {$this->saveConfigInputfields($field, $template, $parent);}$helper = $this->getRepeaterConfigHelper($field);$inputfields = $helper->getConfigInputfields($inputfields, $template, $parent);return $inputfields;}/*** Helper to getConfigInputfields, handles adding and removing of repeater fields** @param Field $field* @param Template $template* @param Page $parent**/protected function ___saveConfigInputfields(Field $field, Template $template, Page $parent) {$this->initAllFields();$helper = $this->getRepeaterConfigHelper($field);$helper->saveConfigInputfields($template);}/*** Just here to fulfill ConfigurableModule interface** @param array $data* @return InputfieldWrapper**/public function getModuleConfigInputfields(array $data) {if($data) {} // ignorereturn $this->wire(new InputfieldWrapper());}/*** Remove advanced options that aren't supposed with repeaters** @param Field $field* @return InputfieldWrapper**/public function ___getConfigAdvancedInputfields(Field $field) {$inputfields = parent::___getConfigAdvancedInputfields($field);$this->getRepeaterConfigHelper($field)->getConfigAdvancedInputfields($inputfields);return $inputfields;}/*** Install the module**/public function ___install() {$pages = $this->wire()->pages;$config = $this->wire()->config;$modules = $this->wire()->modules;$adminRoot = $pages->get($config->adminRootPageID);$page = $adminRoot->child("name=repeaters, template=admin, include=all");if(!$page->id) {$page = $pages->newPage('admin');$page->parent = $adminRoot;$page->status = Page::statusHidden | Page::statusLocked | Page::statusSystemID;$page->name = self::repeatersRootPageName;$page->title = 'Repeaters';$page->sort = $adminRoot->numChildren;$page->save();$this->message("Added page {$page->path}", Notice::debug);}$configData = array('repeatersRootPageID' => $page->id);$modules->saveModuleConfigData($this, $configData);}/*** Uninstall the module (delete the repeaters page)**/public function ___uninstall() {$pages = $this->wire()->pages;// don't delete repeaters page unless actually for FieldtypeRepeaterif($this->className() != 'FieldtypeRepeater') return;$page = $this->getRepeatersRootPage();if($page->id && $page->name === self::repeatersRootPageName && $page->template->name === 'admin') {$page->addStatus(Page::statusSystemOverride);$page->removeStatus(Page::statusSystem);$page->removeStatus(Page::statusSystemID);$page->removeStatus(Page::statusSystemOverride);$page->removeStatus(Page::statusLocked);$pages->delete($page);$this->message("Removed page {$page->path}", Notice::debug);}}}