<?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 2024 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, FieldtypeDoesVersions {

	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' => 113,
			'autoload' => true,
			'installs' => 'InputfieldRepeater'
		);
	}
	
	const devMode = false; // display verbose TD messages

	const 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 disabled
		if(!$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 field
			if(isset($_SERVER['HTTP_X_FIELDNAME'])) {
				// initialize all repeater fields so RepeaterPage class names are active for access control
				if(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 condition
			if(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 User
			if($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 repeaters
		if(!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 filter
		if(!$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 appropriate
		if(!$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 results
		if($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 abort
		if(!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 ownerPage
		if($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 subfield
			list($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 repeater
		foreach($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 repeater
		if($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 used
		if($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->getCustomPageClass($field);
		/** @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 it
		if(!is_array($value) && $value instanceof PageArray) return $value; 

		// if this field has no parent set, just return a blank pageArray
		if(!$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) ((string) $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 pages
		if(!$template_id) $template_id = $this->getRepeaterTemplate($field)->id; 

		// if we were unable to determine a parent for some reason, then just return a blank pageArray
		if(!$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 pages
			if($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->getCustomPageClass($field),
				'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 it
		if(is_array($value)) return $sleepValue; 
	
		// if $value isn't a PageArray, then abort	
		if(!$value instanceof PageArray) return array();
		
		/** @var RepeaterPageArray $value */

		$numPublished = 0; 
		$numTotal = 0;
		$ids = array();

		// iterate through the array and count how many published we have
		foreach($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()) {
		return $this->porter()->exportValue($page, $field, $value, $options);
	}

	/**
	 * 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()) {
		return $this->porter()->importValue($page, $field, $value, $options);
	}

	/**
	 * 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 only
		return $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
		$version = $this->getPageVersionNum($page);
		
		if($version) $parentName .= "-v$version"; // for-page-123-v1
		
		$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 . ($version ? " > v$version" : "");
		$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-created
		if($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 yet
		if(!$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 now
		if($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; 
	}

	/**
	 * #pw-internal
	 * 
	 * @param Field $field
	 * @return Template
	 * 
	 */
	public function _getRepeaterTemplate(Field $field) {
		return $this->getRepeaterTemplate($field);
	}

	/**
	 * 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 it
		if($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 page
		if(!$value) return $pageArray; 

		// if it's a string, see if we can convert it to a Page or PageArray
		if(is_string($value)) $value = $this->sanitizeValueString($page, $field, $value); 

		// if it's a Page, and not  NullPage, add it to the existing PageArray
		if($value instanceof Page) {
			$pageArray->add($value); 
			return $pageArray; 
		}

		// if it's a new PageArray, combine it with the existing PageArray
		if($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 here
		foreach($value as $p) {
			$p->of(true); // ensure RepeaterPage items also formatted
			$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--;
			}
		}
		
		if($formatted) {
			foreach($formatted as $p) $p->of(true);
		}

		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 index
			if(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 value
				if($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 getMatchQuery
			if(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 saved 
		foreach($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 saved
		foreach($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 removed
		foreach($deletePages as $p) {
			/** @var Page|RepeaterPage $p */
			// if the deleted value is still present in the pageArray, then don't delete it
			if($value->has($p)) continue; 
			// $this->message("Deleted Repeater", Notice::debug); 
			// delete the repeater page
			$pages->delete($p, $saveOptions);
		}

		$version = $this->getPageVersionNum($page);
		$pagesVersions = $version ? $this->wire('pagesVersions') : null;
		if($version && $pagesVersions) {
			/** @var PagesVersions $pagesVersions */
			$result = $pagesVersions->savePageFieldVersion($page, $field, $version);
		} else {
			$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 it
			if(!$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 anything
		if($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 parents
			foreach($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 it
		if($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 manage
		if($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);
		$data = $this->porter()->exportConfigData($field, $data);
		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;
		
		$errors = array();
		$data = $this->porter()->importConfigData($field, $data, $errors);
		$data = parent::___importConfigData($field, $data);

		if(count($errors)) {
			if(!isset($data['errors'])) $data['errors'] = array();
			$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-456
			if(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|null $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 parent
			if($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 statuses
		if($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) {} // ignore
		return $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 FieldtypeRepeater
		if($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); 
		}
	}

	/**
	 * @return FieldtypeRepeaterPorter
	 * 
	 */
	public function porter() {
		require_once(__DIR__ . '/FieldtypeRepeaterPorter.php');
		return $this->wire(new FieldtypeRepeaterPorter());
	}
	
	/*************************************************************************
	 * CUSTOM REPEATER PAGE CLASSES
	 *
	 */

	/**
	 * Cache for getCustomPageClass method
	 *
	 * @var array
	 *
	 */
	protected $customPageClasses = array();

	/**
	 * Get custom RepeaterPage class for given repeater field
	 * 
	 * This method returns a custom `RepeaterPage` class that lives in the
	 * /site/classes directory, or it returns a class previously defined
	 * by a call to `FieldtypeRepeater::setCustomPageClass()`. It is 
	 * unlikely you will ever need to call this method, as it is used
	 * internally. But the examples included here describe how to create
	 * and define the custom repeater page class files that this method
	 * would look for. 
	 *
	 * ~~~~~
	 * // To use for field named "hello_world" add file:
	 * // site/classes/HelloWorldRepeaterPage.php
	 * // with the following contents (after opening PHP tag):
	 * namespace ProcessWire;
	 * class HelloWorldRepeaterPage extends RepeaterPage {
	 *   // whatever you want here
	 * }
	 * 
	 * // If using RepeaterMatrix then file would be:
	 * // site/classes/HelloWorldRepeaterMatrixPage.php
	 * // and contents would be:
	 * namespace ProcessWire;
	 * class HelloWorldRepeaterMatrixPage extends RepeaterMatrixPage {
	 *   // whatever you want here
	 * }
	 * 
	 * // If using FieldsetPage then file would be:
	 * // site/classes/HelloWorldFieldsetPage.php
	 * // and contents would be:
	 * namespace ProcessWire;
	 * class HelloWorldFieldsetPage extends FieldsetPage {
	 *   // whatever you want here
	 * }
	 * ~~~~~
	 *
	 * @param Field $field Your Repeater, RepeaterMatrix or FieldsetPage field
	 * @return string Returns the custom page class (with namespace)
	 * @since 3.0.235
	 * @see FieldtypeRepeater::setCustomPageClass()
	 *
	 */
	public function getCustomPageClass(Field $field) {
		if(isset($this->customPageClasses[$field->id])) {
			return $this->customPageClasses[$field->id];
		}
		$nsBaseClass = $this->getPageClass();
		$baseClass = wireClassName($nsBaseClass);
		$name = ucfirst($field->name);
		if(strpos($name, '_')) {
			list($a, $name) = array(explode('_', $name), '');
			foreach($a as $v) $name .= ucfirst($v);
		}
		$customClass = $name . $baseClass;
		$nsCustomClass = __NAMESPACE__ . "\\$customClass";
		$path = $this->wire()->config->paths->classes;
		$customFile = $path . $customClass . '.php';
		if(file_exists($customFile)) include_once($customFile);
		$nsClass = class_exists($nsCustomClass) ? $nsCustomClass : $nsBaseClass;
		if($nsClass !== $nsBaseClass && !wireInstanceOf($nsClass, $nsBaseClass)) $nsClass = $nsBaseClass;
		$this->customPageClasses[$field->id] = $nsClass;
		return $nsClass;
	}

	/**
	 * Set custom RepeaterPage class for given field
	 * 
	 * If using class names/files as described in the `FieldtypeRepeater::getCustomPageClass()`
	 * method, then you DO NOT need to call this, as it can figure those out
	 * automatically. Use this method only if you want to devise your own 
	 * custom page class(es) that use a different naming scheme or are located
	 * somewhere other than /site/classes/.
	 * 
	 * ~~~~~
	 * // example demonsrated in /site/ready.php
	 * class MyCustomClass extends RepeaterPage {}
	 * $field = $fields->get('repeater_field_name');
	 * $field->type->setCustomPageClass($field, 'MyCustomClass'); 
	 * ~~~~~
	 *
	 * @param Field $field Field that custom page class will be for.
	 * @param string $class If given class has no namespace then ProcessWire namespace assumed.
	 * @throws WireException If given class cannot be loaded or does not extend the base class.
	 * @since 3.0.235
	 * @see FieldtypeRepeater::getCustomPageClass()
	 *
	 */
	public function setCustomPageClass(Field $field, $class) {
		if(empty($class)) {
			unset($this->customPageClasses[$field->id]);
			return;
		}
		$baseClass = $this->getPageClass();
		if(strpos($class, "\\") === false) {
			$nsClass = __NAMESPACE__ . "\\$class";
		} else {
			$nsClass = $class;
			$class = wireClassName($class);
		}
		if(!class_exists($nsClass)) {
			if(strpos($class, "\\") === false) {
				$file = $this->wire()->config->paths->classes . "$class.php";
				if(file_exists($file)) include_once($file);
			}
			if(!class_exists($nsClass)) {
				throw new WireException("Cannot find custom class: $nsClass");
			}
		}
		if(!wireInstanceOf($nsClass, $baseClass)) {
			throw new WireException("Class $nsClass does not extend $baseClass");
		}
		$this->customPageClasses[$field->id] = $nsClass;
	}

	/*************************************************************************
	 * VERSION SUPPORT
	 * 
	 */
	
	protected $versions = null;
	
	/**
	 * #pw-internal
	 * 
	 * @return FieldtypeRepeaterVersions
	 * @since 3.0.232
	 * 
	 */
	public function versions() {
		if($this->versions === null) {
			require_once(__DIR__ . '/FieldtypeRepeaterVersions.php');
			$this->versions = new FieldtypeRepeaterVersions($this); 
		}
		return $this->versions;
	}

	/**
	 * Get page version number
	 * 
	 * #pw-internal
	 * 
	 * @param Page $page
	 * @return int
	 * @since 3.0.232
	 *
	 */
	public function getPageVersionNum(Page $page) {
		return $this->versions()->getPageVersionNum($page);
	}

	/**
	 * Get the value for given page, field and version
	 * 
	 * #pw-internal for FieldtypeDoesVersions interface
	 *
	 * @param Page $page
	 * @param Field $field
	 * @param int $version
	 * @return mixed
	 * @since 3.0.232
	 *
	 */
	public function getPageFieldVersion(Page $page, Field $field, $version) {
		return $this->versions()->getPageFieldVersion($page, $field, $version);
	}

	/**
	 * Save version of given page field
	 * 
	 * #pw-internal for FieldtypeDoesVersions interface
	 *
	 * @param Page $page
	 * @param Field $field
	 * @param int $version
	 * @return bool
	 * @since 3.0.232
	 *
	 */
	public function savePageFieldVersion(Page $page, Field $field, $version) {
		return $this->versions()->savePageFieldVersion($page, $field, $version);
	}

	/**
	 * Restore version of given page field
	 * 
	 * #pw-internal for FieldtypeDoesVersions interface
	 *
	 * @param Page $page
	 * @param Field $field
	 * @param int $version
	 * @return bool
	 * @since 3.0.232
	 *
	 */
	public function restorePageFieldVersion(Page $page, Field $field, $version) {
		return $this->versions()->restorePageFieldVersion($page, $field, $version);
	}

	/**
	 * Delete version
	 * 
	 * #pw-internal for FieldtypeDoesVersions interface
	 *
	 * @param Page $page
	 * @param Field $field
	 * @param int $version
	 * @return bool
	 * @since 3.0.232
	 *
	 */
	public function deletePageFieldVersion(Page $page, Field $field, $version) {
		return $this->versions()->deletePageFieldVersion($page, $field, $version);
	}

}
