Subversion Repositories web.active

Rev

Rev 23 | Blame | Compare with Previous | Last modification | View Log | Download

<?php namespace ProcessWire;

/**
 * ProcessWire File Fieldtype
 *
 * Field that stores one or more files with optional description. 
 *
 * For documentation about the fields used in this class, please see:  
 * /wire/core/Fieldtype.php
 * /wire/core/FieldtypeMulti.php
 * 
 * ProcessWire 3.x, Copyright 2023 by Ryan Cramer
 * https://processwire.com
 *
 * @property array $allowFieldtypes Allowed Fieldtype types for custom fields
 * @property string $defaultFileExtensions
 * @method string formatValueString(Page $page, Field $field, $value)
 *
 */

class FieldtypeFile extends FieldtypeMulti implements ConfigurableModule, FieldtypeHasFiles, FieldtypeHasPagefiles {
  
  public static function getModuleInfo() {
    return array(
      'title' => __('Files', __FILE__),
      'version' => 107,
      'summary' => __('Field that stores one or more files', __FILE__),
      'permanent' => true,
    );
  }

  /**
   * outputFormat: Automatic (single item or null when max files set to 1, array of items otherwise)
   * 
   */
  const outputFormatAuto = 0;

  /**
   * outputFormat: Array of items
   * 
   */
  const outputFormatArray = 1;

  /**
   * outputFormat: Single item or null when empty
   * 
   */
  const outputFormatSingle = 2;

  /**
   * outputFormat: String that renders the item
   * 
   */
  const outputFormatString = 30;

  /**
   * File schema is configured to support tags (flag)
   *
   */
  const fileSchemaTags = 1; 

  /**
   * File schema is configured to support 'created' and 'modified' dates (flag)
   *
   */
  const fileSchemaDate = 2;

  /**
   * File schema is configured to support 'filedata' encoded data (flag)
   *
   */
  const fileSchemaFiledata = 4;

  /**
   * File schema is configured to store 'filesize' and created/modified users (flag)
   * 
   */
  const fileSchemaFilesize = 8;

  /**
   * Flag for useTags: tags off/disabled
   * 
   */
  const useTagsOff = 0;

  /**
   * Flag for useTags: normal text input tags
   * 
   */
  const useTagsNormal = 1;

  /**
   * Flag for useTags: predefined tags
   * 
   */
  const useTagsPredefined = 8;
  
  /**
   * Auto-update non-present settings in DB during wakeup?
   * 
   * @since 3.0.154 for enabling true when in development
   *
   */
  const autoUpdateOnWakeup = false;

  /**
   * Default class for Inputfield object used, auto-generated at construct
   * 
   * @var string
   * 
   */
  protected $defaultInputfieldClass = '';

  /**
   * Default fieldtypes allowed for custom fields
   * 
   * @var array
   *
   */
  protected $defaultAllowFieldtypes = array(
    'Checkbox',
    'Datetime',
    'Email',
    'FieldsetClose',
    'FieldsetOpen',
    'Float',
    'Integer',
    'Page',
    'PageTitle',
    'PageTitleLanguage',
    'Text',
    'TextLanguage',
    'Textarea',
    'TextareaLanguage',
    'Toggle',
    'URL',
  );


  /**
   * Construct
   * 
   */
  public function __construct() {
    $this->defaultInputfieldClass = str_replace('Fieldtype', 'Inputfield', $this->className);
    if($this->className() === 'FieldtypeFile') $this->allowFieldtypes = $this->defaultAllowFieldtypes;
    parent::__construct();
  }
  
  public function get($key) {
    if($key === 'defaultFileExtensions') return $this->getDefaultFileExtensions();
    return parent::get($key);
  }

  /**
   * Get the Inputfield module to handle input for this Fieldtype
   * 
   * @param Page $page
   * @param Field $field
   * @return Inputfield
   * 
   */
  public function getInputfield(Page $page, Field $field) {

    $inputfield = null;
    $inputfieldClass = $field->get('inputfieldClass');
    if($inputfieldClass) $inputfield = $this->wire()->modules->get($inputfieldClass); 
    if(!$inputfield) $inputfield = $this->wire()->modules->get($this->defaultInputfieldClass); 
    /** @var Inputfield $inputfield */
    $inputfield->class = $this->className();

    $this->setupHooks($page, $field, $inputfield); 
  
    if(!$field->get('extensions')) {
      if($page->id && $page->template->fieldgroup->hasField($field)) {
        // message that appears on page being edited with this field
        $this->error(sprintf($this->_('Field "%s" is not yet ready to use and needs to be configured.'), $field->name)); 
      } else if(!count($_POST)) {
        // message that appears during configuration, but suppressed during post to prevent it from appearing after save
        $this->message(
          $this->_('Settings have not yet been committed.') . "<br /><small>" . 
          $this->_('Please review the settings on this page and save once more (even if you do not change anything) to confirm you accept them.') . "</small>", 
          Notice::allowMarkup | Notice::noGroup); 
      }
    }

    return $inputfield;
  }

  /**
   * Get setup options and setup functions for new fields
   *
   * @return array
   * @since 3.0.213
   *
   */
  public function ___getFieldSetups() {
    $setups = parent::___getFieldSetups();
    $setups['single'] = array(
      'title' => $this->_('Single file'),
      'maxFiles' => 1,
      'textformatters' => 'TextformatterEntities',
    );
    $setups['multiple'] = array(
      'title' => $this->_('Multiple files'), 
      'maxFiles' => 0,
      'textformatters' => 'TextformatterEntities',
    );  
    return $setups;
  }

  /**
   * Get compatible Fieldtypes
   * 
   * @param Field $field
   * @return Fieldtypes
   * 
   */
  public function ___getCompatibleFieldtypes(Field $field) {
    $fieldtypes = $this->wire(new Fieldtypes());
    foreach($this->wire()->fieldtypes as $fieldtype) {
      if($fieldtype instanceof FieldtypeFile) $fieldtypes->add($fieldtype); 
    }
    return $fieldtypes; 
  }

  /**
   * Setup any necessary hooks for this Inputfield, intended to be called from getInputfield() method
   *
   * We're going to hook into the inputfield to set the upload destination path.
   * This is because the destination path may be determined by events that occur
   * between the time this method is executed, and the time the upload is saved.
   * An example is the page files draft path vs. the published path.
   *
   * Make sure that any Fieldtype's descended from this one call the setupHooks method in their getInputfield()
   * method. 
   * 
   * @param Page $page
   * @param Field $field
   * @param Inputfield $inputfield
   *
   */
  protected function setupHooks(Page $page, Field $field, Inputfield $inputfield) {

    $options = array(
      'page' => $page, 
      'field' => $field, 
    );

    $inputfield->addHookBefore('processInput', $this, 'hookProcessInput', $options); 
  }

  /**
   * Hook into the InputfieldFile's processInput method to set the upload destination path
   *
   * This hook originates with the setupHooks method above. 
   * 
   * @param HookEvent $event
   *
   */
  public function hookProcessInput($event) {
    /** @var InputfieldFile $inputfield */
    $inputfield = $event->object;   
    $page = $event->options['page']; /** @var Page $page */
    $field = $event->options['field']; /** @var Field $field */
    $pagefiles = $page->get($field->name); 
    $inputfield->destinationPath = $pagefiles->path();
  }

  /**
   * @param Page $page
   * @param Field $field
   * @return array|null
   * 
   */
  public function ___loadPageField(Page $page, Field $field) {
    
    $n = 0;
    $retry = false;
    $result = null;
    
    do {
      try {
        $result = parent::___loadPageField($page, $field);
      } catch(\PDOException $e) {
        // retry to apply new schema (this can eventually be removed)
        $fileSchema = (int) $field->get('fileSchema');
        // 42S22=Column not found
        if($e->getCode() !== '42S22' || $n > 0 || !($fileSchema & self::fileSchemaFilesize)) throw $e; 
        $field->set('fileSchema', $fileSchema & ~self::fileSchemaFilesize);
        $this->getDatabaseSchema($field); 
        $retry = true;
      }
    } while($retry && ++$n < 2); 
    
    return $result;
  }

  /**
   * Given a raw value (value as stored in DB), return the value as it would appear in a Page object
   *
   * @param Page $page
   * @param Field $field
   * @param string|int|array $value
   * @return Pagefiles|null $value
   *
   */
  public function ___wakeupValue(Page $page, Field $field, $value) {

    if($value instanceof Pagefiles) return $value; 
    
    $pagefiles = $this->getBlankValue($page, $field); 
    if(empty($value)) return $pagefiles; 
  
    if(!is_array($value) || array_key_exists('data', $value)) {
      $value = array($value);
    }
    
    foreach($value as $a) {
      if(empty($a['data'])) continue;
      $this->wakeupFile($page, $field, $pagefiles, $a);
    }
  
    $pagefiles->resetTrackChanges(true); 
    
    return $pagefiles;  
  }

  /**
   * Wakeup individual file converting array of data to Pagefile and adding it to Pagefiles
   * 
   * @param Page $page
   * @param Field $field
   * @param Pagefiles $pagefiles
   * @param array $a Data from DB to create Pagefile from
   * @return Pagefile The Pagefile object that was added
   * 
   */
  protected function wakeupFile(Page $page, Field $field, Pagefiles $pagefiles, array $a) {

    $pagefile = $this->getBlankPagefile($pagefiles, $a['data']);
    $pagefile->description(true, $a['description']);
  
    $columns = array('modified', 'created', 'tags', 'modified_users_id', 'created_users_id'); 
    
    foreach($columns as $column) {
      if(isset($a[$column])) $pagefile->set($column, $a[$column]);
    }
    
    if(!empty($a['filedata'])) {
      $filedata = json_decode($a['filedata'], true);
      unset($filedata['ix']);
      $pagefile->filedata = $filedata;
    }

    $pagefile->isNew(false);
    $pagefile->setTrackChanges(true);
    $pagefiles->add($pagefile);
  
    if(!empty($a['filesize'])) {
      // @todo 
      // $pagefile->setQuietly('filesize', (int) $a['filesize']);
    } else if(((int) $field->get('fileSchema')) & self::fileSchemaFilesize) {
      // populate file size into DB row
      if(self::autoUpdateOnWakeup) {
        $this->saveFileCols($page, $field, $pagefile, array('filesize' => $pagefile->filesize()));
      }
    }
    
    return $pagefile;
  }
  
  /**
   * Given an 'awake' value, as set by wakeupValue, convert the value back to a basic type for storage in DB. 
   *              
   * @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 instanceof Pagefiles) return $sleepValue; 
  
    foreach($value as $pagefile) {
      /** @var Pagefile $pagefile */
      $item = $this->sleepFile($page, $field, $pagefile);
      $sleepValue[] = $item;
    }
    return $sleepValue;
  }

  /**
   * Convert individual Pagefile to array for storage in DB
   * 
   * @param Page $page
   * @param Field $field
   * @param Pagefile $pagefile
   * @return array
   * 
   */
  protected function sleepFile(Page $page, Field $field, Pagefile $pagefile) {
    
    $isNew = $pagefile->isNew();
    $isChanged = $pagefile->isChanged();
    
    if($isNew) {
      $pagefile->createdUser = $this->wire()->user;
      if(!$pagefile->isTemp()) $pagefile->created = time();
    }
    
    if($isChanged || $isNew) {
      $changes = array_flip($pagefile->getChanges());
      unset($changes['hash'], $changes['sort'], $changes['modified'], $changes['modified_users_id']); 
      if($isNew || count($changes)) {
        $pagefile->modifiedUser = $this->wire()->user;
        if(!$pagefile->isTemp()) $pagefile->modified = time();
      }
    }
    
    $item = array(
      'data' => $pagefile->basename,
      'description' => $pagefile->description(true),
    );

    $fileSchema = (int) $field->get('fileSchema');

    if($fileSchema & self::fileSchemaDate) {
      $item['modified'] = date('Y-m-d H:i:s', $pagefile->modified);
      $item['created'] = date('Y-m-d H:i:s', $pagefile->created);
    }

    if($fileSchema & self::fileSchemaFilesize) {
      $item['filesize'] = $pagefile->filesize(true);
      $item['modified_users_id'] = (int) $pagefile->modified_users_id;
      $item['created_users_id'] = (int) $pagefile->created_users_id;
    }

    if($fileSchema & self::fileSchemaTags) {
      $item['tags'] = $pagefile->tags;
    }

    if($fileSchema & self::fileSchemaFiledata) {
      $filedata = $this->sleepFiledata($field, $pagefile);
      if(empty($filedata)) {
        $item['filedata'] = null;
      } else {
        $item['filedata'] = json_encode($filedata);
      }
    }

    return $item;
  }

  /**
   * Get the filedata from given $pagefile ready for placement in a sleep value
   * 
   * @param Field $fileField Field having type FieldtypeFile or FieldtypeImage (or derivative)
   * @param Pagefile|Pageimage $pagefile
   * @return array Sleep value array
   * @since 3.0.142
   * 
   */
  protected function sleepFiledata(Field $fileField, Pagefile $pagefile) {
    
    $filedata = $pagefile->filedata;
    $template = $this->getFieldsTemplate($fileField);
    
    if(!$template) return $filedata; // custom field template not in use, return filedata as-is
    if(!is_array($filedata)) $filedata = array();

    $fieldValues = $pagefile->fieldValues; // field values that were loaded
    $fieldgroup = $template->fieldgroup;
    $idKeys = array(); // _123 type field ID keys that are populated
    $mockPage = $this->getFieldsPage($fileField);

    // sleep values from pagefile->fieldValues and place back into filedata
    foreach($fieldValues as $key => $value) {
      $field = $fieldgroup->getFieldContext($key);
      if(!$field) continue;
      $idKey = "_$field->id";
      if($value === null) {
        // null to remove value
        unset($filedata[$idKey]);
        continue;
      }
      $sleepValue = $field->type->sleepValue($mockPage, $field, $value);
      if($sleepValue === null) {
        unset($filedata[$idKey]); 
      } else {
        $filedata[$idKey] = $sleepValue;
        $idKeys[$idKey] = $idKey;
      }
    }
  
    // check for data that should no longer be here
    // validate that all field ID keys resolve to fields in the fieldgroup
    foreach($filedata as $key => $value) {
      if(isset($idKeys[$key])) continue; // valid, skip
      if(strpos($key, '_') !== 0) continue; // some other filedata, skip
      $fieldID = ltrim($key, '_'); 
      if(!ctype_digit($fieldID)) continue; // not a custom field, skip
      if($fieldgroup->hasField((int) $fieldID)) continue; // valid, skip
      unset($filedata[$key]); // at this point, it can be removed
    }

    // build fieldValues index
    $index = array();
    foreach($filedata as $idKey => $value) {
      $id = ltrim($idKey, '_');
      if(!ctype_digit($id)) continue;
      $key = "{$id}_";
      $indexValue = $this->filedataIndexValue($key, $value);
      if($indexValue !== null) $index[] = $indexValue;
    }
    unset($filedata['ix']);
    if(count($index)) $filedata['ix'] = implode(' ', $index);
    
    return $filedata;
  }

  /**
   * Get indexable value for filedata or null if not one that will be indexed
   * 
   * @param string $key
   * @param mixed $value
   * @return null|string
   * 
   */
  protected function filedataIndexValue($key, $value) {
    
    $indexValue = null;

    if(is_array($value) && count($value) && !is_array(reset($value))) {
      $a = array();
      foreach($value as $k => $v) {
        if(is_array($v) || !strlen("$v")) continue;
        $kk = is_int($k) ? $key : "{$key}{$k}_";
        $v = $this->filedataIndexValue($kk, $v);
        if($v !== null) $a[] = $v;
      }
      if(count($a)) $indexValue = implode(' ', $a);

    } else if(is_int($value)) {
      // integer index
      $indexValue = "$key$value";

    } else if(is_string($value) && ctype_alnum($value) && strlen($value) <= 128) {
      // one word string
      $indexValue = "$key$value";
    }
    
    return $indexValue;
  }

  /**
   * Export value
   * 
   * @param Page $page
   * @param Field $field
   * @param array|float|int|null|object|string $value
   * @param array $options
   * @return array|float|int|string
   * 
   */
  public function ___exportValue(Page $page, Field $field, $value, array $options = array()) {
  
    /** @var Pagefiles $pagefiles */
    $pagefiles = $value; 
    $value = $this->sleepValue($page, $field, $value); 
    $exportValue = array();
    $defaults = array(
      'noJSON' => false, // no JSON for exported file descriptions or other properties (use array instead)
    );
    if(!isset($options['FieldtypeFile'])) $options['FieldtypeFile'] = array();
    $options['FieldtypeFile'] = array_merge($defaults, $options['FieldtypeFile']); 
    
    foreach($value as $k => $v) {
      /** @var Pagefile $pagefile */
      $pagefile = $pagefiles->get($v['data']); 
      
      $a = array(
        'url' => $pagefile->httpUrl(),
        'size' => $pagefile->filesize(), 
      ); 
      
      if(!empty($options['system'])) {
        unset($v['created'], $v['modified']);
        $exportKey = $v['data'];
      } else {
        $a['name'] = $v['data'];
        $exportKey = $k;
      }
      
      unset($v['data']); 
  
      if($options['FieldtypeFile']['noJSON']) {
        // export version 2 for exported description uses array value for multi-language, rather than JSON string
        if(!isset($v['description'])) $v['description'] = '';
        $v['description'] = $this->exportDescription($v['description']);
      }
      
      $exportValue[$exportKey] = array_merge($a, $v);
    }
    
    return $exportValue;  
  }

  /**
   * Export description value to array (multi-language, indexed by lang name) or string (non-multi-language)
   * 
   * @param string|array $value
   * @return array|string
   * 
   */
  protected function exportDescription($value) {
    
    $languages = $this->wire()->languages;
    
    if(is_string($value)) {
      if(strpos($value, '[') !== 0 && strpos($value, '{') !== 0) return $value;
      $a = json_decode($value, true);
      if(!is_array($a)) return $value;
    } else if(is_array($value)) {
      $a = $value;
    } else {
      $a = array();
    }
  
    if(!$languages) {
      $value = count($a) ? (string) reset($a) : ''; // return first/default
      return $value; 
    }

    $description = array(); 
    
    // ensure value present for every language, even if blank
    foreach($languages as $language) {
      $description[$language->name] = '';
    }
    
    foreach($a as $langKey => $langVal) {
      if(ctype_digit("$langKey")) $langKey = (int) $langKey;
      if(empty($langKey)) {
        $langKey = 'default';
      } else {
        $langKey = $languages->get($langKey)->name;
        if(empty($langKey)) continue;
      }
      $description[$langKey] = $langVal;
    }
    
    return $description;
  }

  /**
   * Get blank value
   * 
   * @param Page $page
   * @param Field $field
   * @return Pagefiles
   * 
   */
  public function getBlankValue(Page $page, Field $field) {
    /** @var Pagefiles $pagefiles */
    $pagefiles = $this->wire(new Pagefiles($page));
    $pagefiles->setField($field); 
    $pagefiles->setTrackChanges(true); 
    return $pagefiles; 
  }

  /**
   * Returns a blank Pagefile instance, which may be another type of Pagefile (i.e. a Pageimage)
   *
   * This method ensures that the correct type of items are populated by wakeupValue()
   *
   * @param Pagefiles $pagefiles
   * @param string $filename
   * @return Pagefile 
   *
   */
  protected function getBlankPagefile(Pagefiles $pagefiles, $filename) {
    return $this->wire(new Pagefile($pagefiles, $filename)); 
  }

  /**
   * Sanitize value
   * 
   * @param Page $page
   * @param Field $field
   * @param mixed $value
   * @return Pagefiles
   * 
   */
  public function sanitizeValue(Page $page, Field $field, $value) {
    if($value instanceof Pagefiles) return $value; 
    $pagefiles = $page->getUnformatted($field->name); 
    if(!$value) return $pagefiles; 
    if($value instanceof Pagefile) return $pagefiles->add($value); 
    if(!is_array($value)) $value = array($value); 
    foreach($value as $file) $pagefiles->add($file); 
    return $pagefiles; 
  }

  /**
   * Perform output formatting on the value delivered to the API
   *
   * Entity encode the file's description field. 
   * 
   * If the maxFiles setting is 1, then we format the value to dereference as single Pagefile rather than a
   * PagefilesArray
   *
   * This method is only used when $page->outputFormatting is true. 
   * 
   * @param Page $page
   * @param Field $field
   * @param Pagefiles $value
   * @return Pagefiles|Pagefile
   *
   */
  public function ___formatValue(Page $page, Field $field, $value) {
    
    if(!$value instanceof Pagefiles) return $value; 
    
    /** @var Pagefiles $value */
    
    $modules = $this->wire()->modules;

    foreach($value as $pagefile) {
      /** @var Pagefile $pagefile */
      if($pagefile->isTemp()) $value->removeQuietly($pagefile); 
    }
    
    $textformatters = $field->get('textformatters'); 
    if(!is_array($textformatters)) $textformatters = array();
    if($field->get('entityEncode') && !count($textformatters)) $textformatters[] = 'TextformatterEntities';
    $useTags = (int) $field->get('useTags'); 
  
    foreach($textformatters as $name) {
      /** @var Textformatter $textformatter */
      $textformatter = $modules->get($name); 
      if(!$textformatter) continue;
      foreach($value as $v) {
        /** @var Pagefile $v */
        if($v->formatted()) continue; 
        $description = $v->description;
        $textformatter->formatValue($page, $field, $description); 
        $v->description = $description; 
        if($useTags) {
          $tags = $v->tags; 
          $textformatter->formatValue($page, $field, $tags); 
          $v->tags = $tags; 
        }
      }
    }
    
    foreach($value as $v) {
      /** @var Pagefile $v */
      $v->formatted = true;
    }

    /*
    if($field->entityEncode) { 
      foreach($value as $k => $v) {
        if($v->formatted()) continue; 
        $v->description = htmlspecialchars($v->description, ENT_QUOTES, "UTF-8"); 
        $v->tags = htmlspecialchars($v->tags, ENT_QUOTES, "UTF-8"); 
        $v->formatted = true; 
      }
    }
    */
  
    $count = count($value); 
    $isEmpty = $count == 0;
    $maxFiles = (int) $field->get('maxFiles'); 
    if($maxFiles && $count > $maxFiles) $value = $value->slice(0, $maxFiles); 
    
    switch((int) $field->get('outputFormat')) {
      
      case self::outputFormatArray:
        // we are already in this format so don't need to do anything
        break;
      
      case self::outputFormatSingle:
        $value = $isEmpty ? null : $value->first();
        break;
      
      case self::outputFormatString:
        $value = $this->formatValueString($page, $field, $value); 
        break;
      
      default: // outputFormatAuto
        if($maxFiles == 1) { 
          $value = $isEmpty ? null : $value->first();
        }
    }
    
    if($isEmpty && $field->get('defaultValuePage')) {
      $defaultPage = $this->wire()->pages->get((int) $field->get('defaultValuePage')); 
      if($defaultPage->id && $defaultPage->id != $page->id) {
        $value = $defaultPage->get($field->name); 
      }
    }
    
    return $value; 
  }

  /**
   * Format value when output format is string
   * 
   * @param Page $page
   * @param Field $field
   * @param Pagefiles $value
   * @return string
   * 
   */
  protected function ___formatValueString(Page $page, Field $field, $value) {
    $out = '';
    $outputString = $field->get('outputString');
    if(empty($outputString)) $outputString = '{url}';
    preg_match_all('/\{([_a-zA-Z0-9]+)\}/', $outputString, $matches);
    foreach($value as $item) {
      $s = $outputString;
      foreach($matches[1] as $key => $match) {
        $v = $item->$match;
        $s = str_replace($matches[0][$key], $v, $s);
      }
      $out .= $s;
    }
    return $out; 
  }
  
  /**
   * Render a markup string of the value.
   *
   * @param Page $page Page that $value comes from
   * @param Field $field Field that $value comes from
   * @param mixed $value 
   * @param string $property 
   * @return string|MarkupFieldtype 
   *
   */
  public function ___markupValue(Page $page, Field $field, $value = null, $property = '') {
    if($property === 'count') {
      if($value === null) $value = $page->getUnformatted($field->name);
      return $value instanceof Pagefiles ? $value->count() : ($value instanceof Pagefile ? 1 : 0);
    }
    return parent::___markupValue($page, $field, $value, $property);
  }

  /**
   * Get match query
   * 
   * @param DatabaseQuerySelect|PageFinderDatabaseQuerySelect $query
   * @param string $table
   * @param string $subfield
   * @param string $operator
   * @param mixed $value
   * @return DatabaseQuery|DatabaseQuerySelect
   * @throws PageFinderSyntaxException
   * @throws WireException
   * 
   */
  public function getMatchQuery($query, $table, $subfield, $operator, $value) {
  
    $field = $query->field; 
    $schema = $this->getDatabaseSchema($field); 
    $compareType = Selectors::getOperators(array('getValueType' => 'compareType', 'operator' => $operator)); 
    $isFindOperator = $compareType & Selector::compareTypeFind;
    $isInvalidOperator = false;
    $isInvalidSubfield = false;
    $originalOperator = $operator; 
    
    unset($schema['keys'], $schema['xtra']);
    
    if($subfield) {
      if($subfield === 'created' || $subfield === 'modified') {
        // created or modified date
        if($isFindOperator) {
          $isInvalidOperator = true;
        } else if(ctype_digit(ltrim("$value", "-"))) {
          $value = date('Y-m-d H:i:s', (int) $value);
        } else {
          $value = new \DateTime($value);
          $value = $value->format('Y-m-d H:i:s');
        }

      } else if($subfield === 'modified_users_id' || $subfield === 'created_users_id') {
        if($isFindOperator) {
          $isInvalidOperator = true;
        } else if(ctype_digit("$value")) {
          $value = (int) $value;
        } else {
          $value = $this->wire()->users->get('name=' . $this->wire()->sanitizer->pageName($value))->id;
          if(!$value) {
            $operator = '=';
            $value = -1;
          }
        }
        
      } else if($subfield === 'count') {
        // count match
        if($isFindOperator) $isInvalidOperator = true;
        $value = (int) $value;
        
      } else if(isset($schema[$subfield])) {
        // subfield is a column native to table
        $useInt = stripos($schema[$subfield], 'int') === 0;
        $useFloat = !$useInt && stripos($schema[$subfield], 'float') === 0;
        if(($useInt || $useFloat) && $isFindOperator) $isInvalidOperator = true; 
        if($useInt) $value = (int) $value;
        if($useFloat) $value = (float) $value;
        
      } else if($this->getMatchQuerySubfield($query, $subfield, $operator, $value)) {
        // match custom fields, successfully handled
        
      } else {
        // requested subfield does not match what’s available
        $isInvalidSubfield = true;
      }
    }
    
    if($operator !== $originalOperator) {
      // operator can change above (i.e. getMatchQuerySubfield)
      $compareType = Selectors::getOperators(array('getValueType' => 'compareType', 'operator' => $operator));
      $isFindOperator = $compareType & Selector::compareTypeFind;
    }
  
    if($isInvalidSubfield) {
      throw new PageFinderSyntaxException("Property '$subfield' not recognized in: $query->selector");
      
    } else if($isInvalidOperator) {
      throw new PageFinderSyntaxException("Invalid operator '$operator' for: $field->name.$subfield");

    } else if($isFindOperator) {
      /** @var DatabaseQuerySelectFulltext $ft Fulltext match filename or description */
      $ft = $this->wire(new DatabaseQuerySelectFulltext($query)); 
      $ft->match($table, $subfield, $operator, $value); 

    } else {
      $query = parent::getMatchQuery($query, $table, $subfield, $operator, $value); 
    }
    
    return $query; 
  }

  /**
   * Get match query for custom field selector
   * 
   * @param DatabaseQuerySelect $query
   * @param string $subfield
   * @param string $operator
   * @param mixed $value
   * @return bool
   * @throws PageFinderSyntaxException
   * 
   */
  protected function getMatchQuerySubfield($query, &$subfield, &$operator, &$value) {
    
    $sanitizer = $this->wire()->sanitizer;
    $selector = $query->selector;
    $property = '';

    if($selector && substr_count($selector->field(), '.') > 1) {
      // field in format field.subfield.property
      $parts = explode('.', $selector->field());
      while(count($parts) > 2) $property = array_pop($parts);
    }

    $field = $this->wire()->fields->get($subfield);
    $fileField = $query->field;
    
    if($fileField && !$fileField->type instanceof FieldtypeFile) $fileField = null;
    if(!$field || !$fileField) return false;

    $template = $this->getFieldsTemplate($fileField);
    if(!$template || !$template->fieldgroup->hasField($field)) {
      throw new PageFinderSyntaxException("Field '$fileField->name' does not have subfield '$field->name'");
    }

    if($field->type instanceof FieldtypePage) {
      if($property) {
        throw new PageFinderSyntaxException("Property '$property' not supported in field '" . $selector->field() . "'");
      } else if(!ctype_digit("$value") && $sanitizer->pagePathName($value) === $value) {
        // page path to ID
        $p = $this->wire()->pages->get($value);
        if($p->id && $p->viewable(false)) $value = $p->id;
      }
    }

    if(($operator === '=' || $operator === '!=') && ctype_alnum("$value") && strlen("$value") <= 128) {
      // we can match our index value in filedata[ix]
      $operator = $operator === '=' ? '~=' : '!~=';
      $value = $property ? "{$field->id}_{$property}_$value" : "{$field->id}_$value";

    } else if($operator === '=') {
      $operator = ctype_alnum("$value") ? '~=' : '*='; // ok

    } else if($operator === '!=' && ctype_alnum("$value")) {
      $operator = '!~='; // ok

    } else if(Selectors::getSelectorByOperator($operator, 'compareType') & Selector::compareTypeFind) {
      // ok, text finding operators

    } else {
      throw new PageFinderSyntaxException("Operator $operator is not supported by $this in selector: $selector");
    }

    $subfield = 'filedata';
    
    return true;
  }

  /**
   * Get selector info
   * 
   * @param Field $field
   * @param array $data
   * @return array
   * 
   */
  public function ___getSelectorInfo(Field $field, array $data = array()) {
    $info = parent::___getSelectorInfo($field, $data); 
    $info['subfields']['data']['label'] = $this->_('filename'); 
    $template = $this->getFieldsTemplate($field);
    if($template) {
      foreach($template->fieldgroup as $f) {
        $f = $template->fieldgroup->getFieldContext($f);
        if($f->type instanceof FieldtypePage) {
          $info['subfields'][$f->name] = array(
            'name' => $f->name,
            'label' => $f->getLabel(),
            'operators' => array('=', '!='),
            'input' => 'page',
            'options' => array(),
          );
        } else if($f->type instanceof FieldtypeCheckbox || $f->type->className() === 'FieldtypeToggle') {
          $info['subfields'][$f->name] = $f->type->getSelectorInfo($f, $data); 
        } else {
        }
      }
    }
    return $info;
  }

  /**
   * Get database schema
   * 
   * @param Field $field
   * @return array
   * 
   */
  public function getDatabaseSchema(Field $field) {

    $database = $this->wire()->database;
    $schema = parent::getDatabaseSchema($field);
    $maxLen = $database->getMaxIndexLength();

    $schema['data'] = "varchar($maxLen) NOT NULL";
    $schema['description'] = "text NOT NULL";
    $schema['modified'] = "datetime"; 
    $schema['created'] = "datetime";
    $schema['filedata'] = "mediumtext"; 
    $schema['filesize'] = "int"; // 3.0.154+
    $schema['created_users_id'] = 'int unsigned not null default 0'; // 3.0.154+
    $schema['modified_users_id'] = 'int unsigned not null default 0'; // 3.0.154+
    $schema['keys']['description'] = 'FULLTEXT KEY description (description)';
    $schema['keys']['filedata'] = 'FULLTEXT KEY filedata (filedata)'; 
    $schema['keys']['modified'] = 'index (modified)'; 
    $schema['keys']['created'] = 'index (created)';
    $schema['keys']['filesize'] = 'index (filesize)'; // 3.0.154+
    
    if($field->id && !$field->prevFieldtype) {
      if($field->flags & Field::flagFieldgroupContext) {
        $field = $this->wire()->fields->get($field->name);
      }
      $fileSchema1 = (int) $field->get('fileSchema'); 
      $fileSchema2 = $this->updateDatabaseSchema($field, $schema, $fileSchema1);
      if($fileSchema1 !== $fileSchema2) {
        // update fileSchema flags
        $field->set('fileSchema', $fileSchema2);
        $field->save();
      }
    }
    
    return $schema;
  }
  
  /**
   * Check and update database schema according to current version and features
   * 
   * @param Field $field
   * @param array $schema Updated directly
   * @param int $fileSchema The fileSchema version flags integer
   * @return int Updated fileSchema flags integer
   * @since 3.0.154
   * 
   */
  protected function updateDatabaseSchema(Field $field, array &$schema, $fileSchema) {

    $contextField = $field; 
    if($field->flags & Field::flagFieldgroupContext) $field = $this->wire()->fields->get($field->name);

    $database = $this->wire()->database;
    $table = $database->escapeTable($field->table);

    $hasFilesize = $fileSchema & self::fileSchemaFilesize;
    $hasFiledata = $fileSchema & self::fileSchemaFiledata;
    $hasDate = $fileSchema & self::fileSchemaDate;
    $hasTags = $fileSchema & self::fileSchemaTags;
    $useTags = $field->get('useTags') || $contextField->get('useTags');
    
    if(!$hasFilesize || !$hasFiledata || !$hasDate) { 
      if(!$database->tableExists($table)) {
        // new field being created, getting initial schema to create table
        return $fileSchema;
      }
    }

    // Filesize update (3.0.154): Adds 'filesize', 'created_users_id', 'modified_users_id' columns
    if(!$hasFilesize) {
      $columns = array('filesize', 'created_users_id', 'modified_users_id');
      $numErrors = 0;
      foreach($columns as $column) {
        if(!$this->addColumn($field, $column, $schema)) $numErrors++;
        if($numErrors) break;
      }
      if(!$numErrors) {
        $fileSchema = $fileSchema | self::fileSchemaFilesize;
      }
    }

    // Filedata update: adds 'filedata' column
    if(!$hasFiledata && $this->addColumn($field, 'filedata', $schema)) {
      $fileSchema = $fileSchema | self::fileSchemaFiledata;
    }

    // Date update: Adds 'modified', 'created' columns
    if(!$hasDate) {
      $numErrors = 0;
      if(!$this->addColumn($field, 'created', $schema)) $numErrors++;
      if(!$numErrors && !$this->addColumn($field, 'modified', $schema)) $numErrors++;
      if(!$numErrors) {
        $fileSchema = $fileSchema | self::fileSchemaDate;
        // now populate initial dates
        $date = date('Y-m-d H:i:s');
        $query = $database->prepare("UPDATE `{$table}` SET created=:created, modified=:modified");
        $query->bindValue(":created", $date);
        $query->bindValue(":modified", $date);
        try {
          $query->execute();
          $this->message("Populated initial created/modified dates for '{$field->name}'", Notice::log);
        } catch(\Exception $e) {
          $this->error("Error populating created/modified dates to '{$field->name}'", Notice::log);
        }
      }
    }
  
    // Tags update: adds 'tags' column
    $schemaTags = 'varchar(250) NOT NULL';
    $schemaTagsIndex = 'FULLTEXT KEY tags (tags)';
    if($useTags && !$hasTags) {
      // add tags field
      if($database->columnExists($table, 'tags')) {
        // congrats
        $fileSchema = $fileSchema | self::fileSchemaTags;
      } else try {
        $database->exec("ALTER TABLE `{$table}` ADD tags $schemaTags");
        $database->exec("ALTER TABLE `{$table}` ADD $schemaTagsIndex");
        $this->message("Added tags to DB schema for '{$field->name}'", Notice::log);
        $fileSchema = $fileSchema | self::fileSchemaTags;
      } catch(\Exception $e) {
        $this->error("Error adding tags to '{$field->name}' schema", Notice::log);
      }

    } else if(!$useTags && $hasTags) {
      // remove tags field
      $fileSchema = $fileSchema & ~self::fileSchemaTags;
      /*
      try {
        $database->exec("ALTER TABLE `{$table}` DROP INDEX tags");
        $database->exec("ALTER TABLE `{$table}` DROP tags");
        $this->message("Dropped tags from DB schema for '{$field->name}'", Notice::log);
        $fileSchema = $fileSchema & ~self::fileSchemaTags;
      } catch(\Exception $e) {
        $this->error("Error dropping tags from '{$field->name}' schema", Notice::log);
      }
      */
    }

    if($fileSchema & self::fileSchemaTags) {
      $schema['tags'] = $schemaTags;
      $schema['keys']['tags'] = $schemaTagsIndex;
    }
    
    return $fileSchema;
  }

  /**
   * Adds a column
   * 
   * @param Field $field
   * @param string $column
   * @param array $schema Schema array
   * @return bool
   * @throws WireException
   * 
   */
  protected function addColumn(Field $field, $column, array &$schema) {
    
    $database = $this->wire()->database;
    
    if($database->columnExists($field->table, $column)) return true;
    
    if(!isset($schema[$column])) throw new WireException("Missing schema for $field->name.$column"); 
    
    $table = $database->escapeTable($field->table);
    
    try {
      $result = $database->exec("ALTER TABLE `{$table}` ADD `$column` $schema[$column]");
      if($result !== false) { 
        if(isset($schema['keys'][$column])) {
          $database->exec("ALTER TABLE `{$table}` ADD " . $schema['keys'][$column]);
        }
        $this->message("Added '$column' to DB schema for '$field->name'", Notice::log | Notice::debug);
      }
    } catch(\Exception $e) {
      if($database->columnExists($table, $column)) {
        $result = true; 
      } else {
        $this->error("Error adding '$column' to '{$field->name}' schema", Notice::log | Notice::debug);
        unset($schema[$column], $schema['keys'][$column]);
        $result = false;
      }
    }
    
    return $result;
  }

  /**
   * Delete field from page
   * 
   * @param Page $page
   * @param Field $field
   * @return bool
   * 
   */
  public function ___deletePageField(Page $page, Field $field) {

    /** @var Pagefiles|Pagefile $pagefiles */ 
    $pagefiles = $page->get($field->name);

    if($pagefiles) {
      $dvpID = $field->get('defaultValuePage'); 
      
      if($dvpID && $dvpID != $page->id && $pagefiles->page->id != $page->id) {
        // pagefiles is a default/fallback value from another page and should not be deleted
        
      } else if($pagefiles instanceof Pagefiles) {
        // $pagefiles->removeAll() not used here because it queues delete to $page->save(),
        // which does not occur when a field is removed from a template
        foreach($pagefiles as $pagefile) {
          /** @var Pagefile $pagefile */
          $pagefile->unlink();
        }

      } else if($pagefiles instanceof Pagefile) {
        $pagefiles->unlink();

      } else if($page->hasField($field) && $this->wire()->config->debug) {
        $this->error("Not Pagefiles or Pagefile"); 
      }

    } else if($page->hasField($field) && $this->wire()->config->debug) {
      $this->error("Unable to retreive $page.{$field->name}");      
    }

    parent::___deletePageField($page, $field); 

    return true; 
  }
  
  /**
   * Empty field from page
   *
   * @param Page $page
   * @param Field $field
   * @return bool
   *
   */
  public function ___emptyPageField(Page $page, Field $field) {
    return $this->deletePageField($page, $field);
  }
  
  /**
   * 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) {
    $dstPath = $dst->filesManager()->path();
    if(!parent::___replacePageField($src, $dst, $field)) return false;
    $src->filesManager()->moveFiles($dstPath);
    return true;
  }


  /**
   * Delete field
   * 
   * @param Field $field
   * @return bool
   * 
   */
  public function ___deleteField(Field $field) {
    // delete files not necessary since deletePageField would have been called for all instances before this could be called
    return parent::___deleteField($field); 
  }

  /**
   * Return a cloned copy of $field
   *
   * @param Field $field
   * @return Field cloned copy
   *
   */
  public function ___cloneField(Field $field) {
    $this->wire()->fields->addHookAfter('cloned', function(HookEvent $e) {
      $e->removeHook(null); // run once only
      $oldField = $e->arguments(0); /** @var Field $oldField */
      $newField = $e->arguments(1); /** @var Field $newField */
      if(!$newField->type instanceof FieldtypeFile) return;
      $templates = $e->wire()->templates;
      $oldTpl = $templates->get("field-$oldField|field-x-$oldField");
      if(!$oldTpl) return; // no custom field-* template present
      $newTpl = $templates->clone($oldTpl, str_replace("$oldField", "$newField", $oldTpl->name));
      if($newTpl) {
        $e->message(sprintf(__('Cloned fields template - %1$s => %2$s'), $oldTpl->name, $newTpl->name));
      } else {
        $e->warning(sprintf(__('Error cloning template - %1$s => %2$s'), $oldTpl->name, $newTpl->name));
      }
    });
    return parent::___cloneField($field);
  }
  
  /**
   * Default list of file extensions supported by this field, and used as the default by getConfigInputfields()
   * method. 
   *
   * Subclasses can override with their own string of file extensions
   *
   */
  protected function getDefaultFileExtensions() {
    // note: this method is not public because other modules are implementing it
    // access $fieldtype->defaultFileExtensions to get the value instead
    return "pdf doc docx xls xlsx gif jpg jpeg png";
  }

  /**
   * Get default Inputfield class name
   * 
   * #pw-internal
   * 
   * @return string
   * @since 3.0.170
   * 
   */
  public function getDefaultInputfieldClass() {
    return $this->defaultInputfieldClass;
  }

  /**
   * Get allowable Fieldtypes for custom fields
   *
   * #pw-internal
   *
   * @param bool $getDefaults Get the default setting instead?
   * @return array
   * @since 3.0.170
   *
   */
  public function getAllowFieldtypes($getDefaults = false) {
    if($getDefaults) return $this->defaultAllowFieldtypes;
    return $this->allowFieldtypes;
  }

  /**
   * Disable autojoin for files
   * 
   * @param Field $field
   * @param DatabaseQuerySelect $query
   * @return DatabaseQuerySelect|null
   *
   */
  public function getLoadQueryAutojoin(Field $field, DatabaseQuerySelect $query) {
    return null;
  }

  /**
   * Return Template used to manage fields for given file field
   * 
   * #pw-internal
   * 
   * @param Field $field Field having type FieldtypeFile or FieldtypeImage
   * @return Template|null Returns Template or null if it does not exist
   * @since 3.0.142
   * 
   */
  public function getFieldsTemplate(Field $field) {
    $template = $this->wire()->templates->get('field-' . $field->name);
    if(!$template) return null;
    // prepare fieldgroup used by template
    $fieldgroup = $template->fieldgroup;
    if($this->className() === 'FieldtypeFile') {
      $allowFieldtypes = $this->allowFieldtypes;
    } else {
      $allowFieldtypes = $this->wire()->fieldtypes->get('FieldtypeFile')->get('allowFieldtypes');
    }
    $allowFieldtypes = array_flip($allowFieldtypes);
    foreach($fieldgroup as $f) {
      /** @var Field $f */
      $name = str_replace('Fieldtype', '', $f->type->className());
      if(!isset($allowFieldtypes[$name])) $fieldgroup->softRemove($f);
    }
    return $template;
  }
  
  /**
   * Get a mock/placeholder page for using custom fields in files
   * 
   * #pw-internal
   * 
   * @param Field $field
   * @return Page
   * @since 3.0.142
   * 
   */
  public function getFieldsPage(Field $field) {
    $page = $this->wire()->pages->newPage($this->getFieldsTemplate($field));
    $page->status = Page::statusOn | Page::statusCorrupted; // corrupted status prevents saving
    return $page;
  }

  /**
   * Given a Page and file basename, return the Pagefile object if file is found for Page
   * 
   * The returned Pagefile will have a `field` property that reveals the Field it is from. 
   * 
   * @param Page $page
   * @param string $basename
   * @return Pagefile|null
   * 
   */
  public function getPagefile(Page $page, $basename) {
    $pagefile = null;
    foreach($page->template->fieldgroup as $field) {
      /** @var Field $field */
      if(!($field->type instanceof FieldtypeFile)) continue;
      $pagefiles = $page->get($field->name);
      if($pagefiles instanceof Pagefile) {
        if($pagefiles->basename() === $basename) $pagefile = $pagefiles;
      } else if($pagefiles instanceof Pagefiles) {
        foreach($pagefiles as $f) {
          /** @var Pagefile $f */
          if($f->basename() === $basename) $pagefile = $f;
          if($pagefile) break;
        }
      }
      if($pagefile) break;
    }
    return $pagefile;
  }

  /**
   * Save a single Pagefile to DB
   * 
   * #pw-internal
   * 
   * @param Page $page
   * @param Field $field
   * @param Pagefile $pagefile
   * @param array $columns Update only these column names (for existing Pagefile only)
   * @return bool
   * @throws WireException
   * @since 3.0.154
   * 
   */
  public function saveFile(Page $page, Field $field, Pagefile $pagefile, array $columns = array()) {
    
    $item = $this->sleepFile($page, $field, $pagefile);
    $database = $this->wire()->database;
    $table = $database->escapeTable($field->getTable());
    $sets = array();
    $binds = array(':pages_id' => $page->id);
    $isNew = $pagefile->isNew();
    $columns = !$isNew && count($columns) ? array_flip($columns) : null;
  
    if($pagefile->formatted()) { 
      if(!$columns || isset($columns['filedata']) || isset($columns['description'])) {
        throw new WireException("Cannot save formatted Pagefile: $pagefile");
      }
    }
    
    foreach($item as $key => $value) {
      if($columns && !isset($columns[$key])) continue;
      $sets[] = "$key=:$key" ;
      $binds[":$key"] = $value;
    }
    
    $sets = implode(', ', $sets);
    
    if($isNew) {
      $sql = "INSERT INTO $table SET pages_id=:pages_id, sort=:sort, $sets";
      $sort = $this->getMaxColumnValue($page, $field, 'sort', -1);
      $binds[":sort"] = ++$sort;
    } else {
      $sql = "UPDATE $table SET $sets WHERE pages_id=:pages_id AND data=:name";
      $binds[':name'] = $pagefile->name;
    }
    
    $query = $database->prepare($sql);
    
    foreach($binds as $key => $value) {
      if($value === null) {
        $type = \PDO::PARAM_NULL;
      } else if(is_int($value)) {
        $type = \PDO::PARAM_INT; 
      } else {
        $type = \PDO::PARAM_STR;
      }
      $query->bindValue($key, $value, $type);
    }
    
    $result = $query->execute() ? $query->rowCount() : 0;
    if($isNew && $result) $pagefile->isNew(false);
    
    return (bool) $result;
  }

  /**
   * Specify specific columns and values to update for Pagefile 
   * 
   * This update is performed quietly, not updating 'modified' or 'modified_users_id' 
   * unless specified in given data array $a. 
   * 
   * This method cannot be used to update 'description' or 'filedata' properties, 
   * and it will not save for Pagefile entries not already in the DB. 
   * 
   * #pw-internal
   * 
   * @param Page $page
   * @param Field $field
   * @param Pagefile $pagefile
   * @param array $a Associative array of column names and values
   * @return bool
   * @throws WireException
   * @since 3.0.154
   * 
   */
  public function saveFileCols(Page $page, Field $field, Pagefile $pagefile, array $a) {
    
    $database = $this->wire()->database;
    $table = $database->escapeTable($field->getTable());
    $schema = $this->getDatabaseSchema($field);
    $binds = array(':pid' => $page->id, ':name' => $pagefile->name);
    $sets = array();
    
    if($pagefile->isNew()) {
      throw new WireException("Cannot saveFileCols() on “new” Pagefile $pagefile");
    }
    
    if(empty($a) || !$page->id || !($field->type instanceof FieldtypeFile)) return false;
    
    foreach($a as $col => $value) {
      if(!isset($schema[$col])) {
        throw new WireException("Unknown column '$col' for field $field->name");
      } else if($col === 'filedata' || strpos($col, 'description') === 0) {
        throw new WireException("Column '$col' cannot be saved with $this::saveFileCols()");
      } else if($col === 'created' || $col === 'modified') {
        if(ctype_digit("$value")) $value = date('Y-m-d H:i:s', (int) $value);
      }
      $sets[] = "$col=:$col";
      $binds[":$col"] = $value;
    }
    
    $sql = "UPDATE $table SET " . implode(', ', $sets) . " where pages_id=:pid AND data=:name";
    $query = $database->prepare($sql);
    
    foreach($binds as $key => $value) {
      $query->bindValue($key, $value);
    }
    
    $result = $query->execute() ? $query->rowCount() : 0;
    
    return (bool) $result;
    
  }

  /**
   * Check file extensions for given field and return array of validity information 
   * 
   * @param Field|Inputfield $field
   * @param array $validateExtensions Extensions to require validation for, or omit for default. 
   * @return array Returns associative array with the following:
   *  - `valid` (array): valid extensions, including those that have been whitelisted or are covered by FileValidator modules.
   *  - `invalid` (array): extensions that are potentially bad and have not been whitelisted or covered by a FileValidator module. 
   *  - `whitelist` (array): previously invalid extensions that have been manually whitelisted.
   *  - `validators` (array): Associative array of [ 'ext' => [ 'FileValidatorModule' ] ] showing what’s covered by FileValidator modules.
   * @throws WireException
   * @since 3.0.167
   * 
   */
  public function getValidFileExtensions($field, array $validateExtensions = array()) {
    
    if(!$field instanceof Field && !$field instanceof Inputfield) {
      throw new WireException("This method requires a Field or Inputfield object"); 
    }

    if(empty($validateExtensions)) {
      $validateExtensions = array('svg');
    } else {
      foreach($validateExtensions as $key => $ext) {
        $validateExtensions[$key] = strtolower(trim($ext));
      }
    }

    $extensions = array();
    $badExtensions = array();
    $whitelistExtensions = array();
    $extensionsStr = (string) $field->get('extensions'); 
    $okExtensions = $field->get('okExtensions');
    $validators = array();
    
    if(!is_array($okExtensions)) $okExtensions = array();
    
    $extensionsStr = trim(str_replace(array("\n", ",", "."), ' ', $extensionsStr));
    
    foreach(explode(' ', $extensionsStr) as $ext) {
      $ext = strtolower(trim($ext));
      if(!strlen($ext)) continue;
      $extensions[$ext] = $ext; 
    }

    foreach($extensions as $ext) {
      // check if extension requires a FileValidator
      if(!in_array($ext, $validateExtensions)) continue;
      
      // if extension was manually whitelisted, then accept it as valid
      if(in_array($ext, $okExtensions, true)) {
        $whitelistExtensions[$ext] = $ext;
        continue;
      }

      // if a module validates extension then good
      $moduleNames = $this->wire()->sanitizer->validateFile("test.$ext", array(
        'dryrun' => true, 
        'getArray' => true
      ));
      
      if(count($moduleNames)) {
        $validators[$ext] = $moduleNames;
        continue;
      }
      
      // if extension has no validator then remove it from valid list and add to the naughty list
      unset($extensions[$ext]);
      $badExtensions[$ext] = $ext;
    }
    
    return array(
      'valid' => $extensions, // valid extensions, including those that have been whitelisted
      'invalid' => $badExtensions, // extensions that are potentially bad and have not been whitelisted
      'whitelist' => $whitelistExtensions, // previously invalid extensions that have been whitelisted
      'validators' => $validators, // file validators in use indexed by file extension
    );
  }
  
  /**
   * Whether or not given Page/Field has any files connected with it 
   * 
   * #pw-internal For FieldtypeHasFiles interface
   *
   * @param Page $page
   * @param Field $field
   * @return bool
   * @since 3.0.181
   *
   */
  public function hasFiles(Page $page, Field $field) {
    if(!$field->type instanceof FieldtypeFile) return false;
    $value = $page->get($field->name);
    return ($value instanceof Pagefile || ($value instanceof Pagefiles && $value->count() > 0));
  }

  /**
   * Get array of full path/file for all files managed by given page and field
   * 
   * #pw-internal For FieldtypeHasFiles interface
   *
   * @param Page $page
   * @param Field $field
   * @return array
   * @since 3.0.181
   *
   */
  public function getFiles(Page $page, Field $field) {
    $value = $page->get($field->name); 
    if($value instanceof Pagefile) {
      return array($value->filename());
    }
    if($value instanceof Pagefiles && $value->count()) {
      return $value->explode('filename');
    }
    return array();
  }
  
  /**
   * Get path where files are (or would be) stored
   *
   * @param Page $page
   * @param Field $field
   * @return string
   *
   */
  public function getFilesPath(Page $page, Field $field) {
    return PagefilesManager::_path($page);
  }

  /**
   * Get Pagefiles 
   * 
   * @param Page $page
   * @param Field $field
   * @return Pagefiles
   * 
   */
  public function getPagefiles(Page $page, Field $field) {
    /** @var Pagefiles|Pagefile|null $value */
    $value = $page->get($field->name);
    if($value instanceof Pagefiles) return $value;
    $pagefiles = $this->getBlankValue($page, $field);
    if($value instanceof Pagefile) $pagefiles->add($value);
    return $pagefiles;
  }

  /**
   * Called after field renamed
   *
   * @param Field $field
   * @param string $prevName Previous name (current name can be found in $field->name)
   *
   */
  public function ___renamedField(Field $field, $prevName) {
    // if there is a custom-fields template, rename it
    $templates = $this->wire()->templates;
    if($templates->get("field-$field->name")) return;
    if($templates->get("field-x-$field->name")) return;
    $template = $templates->get("field-$prevName"); 
    if($template) {
      $templates->rename($template, "field-$field->name");
    } else {
      $template = $templates->get("field-x-$prevName");
      if($template) {
        $templates->rename($template, "field-x-$field->name");
      }
    }
  }

  /**
   * Field config
   * 
   * @param Field $field
   * @return InputfieldWrapper
   * 
   */
  public function ___getConfigInputfields(Field $field) {
    $inputfields = parent::___getConfigInputfields($field);
    return $this->fieldtypeConfiguration()->getConfigInputfields($field, $inputfields);
  }

  /**
   * Field advanced config
   *
   * @param Field $field
   * @return InputfieldWrapper
   *
   */
  public function ___getConfigAdvancedInputfields(Field $field) {
    $inputfields = parent::___getConfigAdvancedInputfields($field);
    return $this->fieldtypeConfiguration()->getConfigAdvancedInputfields($field, $inputfields);
  }
  
  /**
   * Module config
   * 
   * @param InputfieldWrapper $inputfields
   *
   */
  public function ___getModuleConfigInputfields(InputfieldWrapper $inputfields) {
    if($this->className() != 'FieldtypeFile') return;
    $this->fieldtypeConfiguration()->getModuleConfigInputfields($inputfields);
  }

  /**
   * @return FieldtypeFileConfiguration
   * 
   */
  protected function fieldtypeConfiguration() {
    require_once(__DIR__ . '/config.php');
    return new FieldtypeFileConfiguration($this);
  }
}