Subversion Repositories web.active

Rev

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 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->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 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) $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->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 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()) {
    
    $a = array();
    if(!WireArray::iterable($value)) return $a;
  
    if(!empty($options['minimal']) || !empty($options['FieldtypeRepeater']['minimal'])) {
      // minimal export option includes only fields data
      
      foreach($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 local
    foreach($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 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
    $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-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; 
  }

  /**
   * 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) {
      $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 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);
    }

    $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);

    $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-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 $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); 
    }
  }

}