Subversion Repositories web.active

Rev

Rev 1 | Go to most recent revision | Blame | Compare with Previous | Last modification | View Log | Download

<?php namespace ProcessWire;

/**
 * An Inputfield for handling file uploads
 *
 * @property string $extensions Allowed file extensions, space separated
 * @property array $okExtensions File extensions that are whitelisted if any in $extensions are problematic. (3.0.167+)
 * @property int $maxFiles Maximum number of files allowed
 * @property int $maxFilesize Maximum file size
 * @property bool $useTags Whether or not tags are enabled
 * @property string $tagsList Predefined tags
 * @property bool|int $unzip Whether or not unzip is enabled
 * @property bool|int $overwrite Whether or not overwrite mode is enabled
 * @property int $descriptionRows Number of rows for description field (default=1, 0=disable)
 * @property string $destinationPath Destination path for uploaded file
 * @property string $itemClass Class name(s) for each file item (default=InputfieldFileItem ui-widget ui-widget-content)
 * @property bool|int $noUpload Set to true or 1 to disable uploading to this field
 * @property bool|int $noLang Set to true or 1 to disable multi-language descriptions
 * @property bool|int $noAjax Set to true or 1 to disable ajax uploading
 * @property int $uploadOnlyMode Set to true or 1 to disable existing file list display, or 2 to also prevent file from having 'temp' status.
 * @property bool|int $noCollapseItem Set to true to disable collapsed items (like for LanguageTranslator tool or other things that add tools to files)
 * @property bool|int $noShortName Set to true to disable shortened filenames in output
 * @property bool|int $noCustomButton Set to true to disable use of the styled <input type='file'>
 * @property Pagefiles|Pagefile|null $value
 *
 * @method string renderItem($pagefile, $id, $n)
 * @method string renderList($value)
 * @method string renderUpload($value)
 * @method void fileAdded(Pagefile $pagefile)
 * @method array extractMetadata(Pagefile $pagefile, array $metadata = array())
 * @method void processInputAddFile($filename)
 * @method void processInputDeleteFile(Pagefile $pagefile)
 * @method bool processInputFile(WireInputData $input, Pagefile $pagefile, $n)
 * @method bool processItemInputfields(Pagefile $pagefile, InputfieldWrapper $inputfields, $id, WireInputData $input)
 *
 */
class InputfieldFile extends Inputfield implements InputfieldItemList, InputfieldHasSortableValue {

  public static function getModuleInfo() {
    return array(
      'title' => __('Files', __FILE__), // Module Title
      'summary' => __('One or more file uploads (sortable)', __FILE__), // Module Summary
      'version' => 126,
      'permanent' => true, 
      );
  }
  
  /**
   * Cache of responses we'll be sending on ajax requests
   *
   */
  protected $ajaxResponses = array();

  /**
   * Was a file replaced? 
   *
   */
  protected $singleFileReplacement = false;

  /**
   * Saved instanceof WireUpload in case API retrieval is needed (see getWireUpload() method)
   *
   */
  protected $wireUpload = null;

  /**
   * Set to the current Pagefile item when doing iteration
   * 
   * @var Pagefile|null
   * 
   */
  protected $currentItem = null;

  /**
   * True when field should behave in an upload only mode
   * 
   * @var bool|int
   * 
   */
  protected $uploadOnlyMode = 0;

  /**
   * This is true when we are only rendering the value rather than the inputs
   * 
   * @var bool
   * 
   */
  protected $renderValueMode = false;

  /**
   * True when in ajax mode
   * 
   * @var bool
   * 
   */
  protected $isAjax = false;

  /**
   * Admin theme specific settings
   * 
   * @var array
   * 
   */
  protected $themeSettings = array();

  /**
   * Commonly used text labels, translated, indexed by label name
   * 
   * @var array
   * 
   */
  protected $labels = array();
  
  /**
   * Cached value of Fieldgroup used for Pagefile custom fields, as used by getItemInputfields() method
   *
   * @var Fieldgroup|null|bool Null when not yet known, false when known not applicable, Fieldgroup when known and in use
   *
   */
  protected $itemFieldgroup = null;

  /**
   * Cached result from FieldtypeFile::getValidFileExtension()
   * 
   * @var array
   * 
   */
  protected $extensionsInfo = array();

  /**
   * Initialize the InputfieldFile
   *
   */
  public function init() {
    parent::init();

    // note: these two fields originate from FieldtypeFile. 
    // Initializing them here ensures this Inputfield has the values set automatically.
    $this->set('extensions', '');
    $this->set('okExtensions', array()); // manually whitelisted problematic extensions
    $this->set('maxFiles', 0); 
    $this->set('maxFilesize', 0); 
    $this->set('useTags', 0);
    $this->set('tagsList', ''); 

    // native to this Inputfield
    $this->set('unzip', 0); 
    $this->set('overwrite', 0); 
    $this->set('descriptionRows', 1); 
    $this->set('destinationPath', ''); 
    $this->set('itemClass', 'InputfieldFileItem ui-widget ui-widget-content'); 
    $this->set('noUpload', 0); // set to 1 to disable uploading to this field
    $this->set('noLang', 0); 
    $this->set('noAjax', 0); // disable ajax uploading
    $this->set('noCollapseItem', 0);
    $this->set('noShortName', 0);
    $this->set('noCustomButton', false);
    $this->attr('type', 'file'); 
    
    $this->labels = array(
      'description' => $this->_('Description'), 
      'tags' => $this->_('Tags'),
      'drag-drop' => $this->_('drag and drop files in here'), 
      'delete' => $this->_('Delete'),
      'choose-file' => $this->_('Choose File'),
      'choose-files' => $this->_('Choose Files'),
    );
    
    $this->isAjax = $this->wire('input')->get('InputfieldFileAjax') 
      || $this->wire('input')->get('reloadInputfieldAjax')
      || $this->wire('input')->get('renderInputfieldAjax');

    $this->setMaxFilesize(trim(ini_get('post_max_size'))); 
    $this->uploadOnlyMode = (int) $this->wire('input')->get('uploadOnlyMode');
    $this->addClass('InputfieldItemList', 'wrapClass');
    $this->addClass('InputfieldHasFileList', 'wrapClass');

    $themeDefaults = array(
      'error' => "<span class='ui-state-error-text'>{out}</span>",
    );
    $themeSettings = $this->wire('config')->InputfieldFile;
    $this->themeSettings = is_array($themeSettings) ? array_merge($themeDefaults, $themeSettings) : $themeDefaults;
  }
  
  public function get($key) {
    if($key === 'renderValueMode') return $this->renderValueMode;
    if($key === 'singleFileReplacement') return $this->singleFileReplacement;
    if($key === 'descriptionFieldLabel') return $this->labels['description'];
    if($key === 'tagsFieldLabel') return $this->labels['tags'];
    if($key === 'deleteLabel') return $this->labels['delete'];
    if($key === 'themeSettings') return $this->themeSettings;
    return parent::get($key);
  }
  
  public function set($key, $value) {
    if($key == 'maxFilesize') return $this->setMaxFilesize($value);
    return parent::set($key, $value); 
  }

  /**
   * Set the max file size in bytes or use string like "30m", "2g" "500k"
   * 
   * @param int|string $filesize
   * @return $this
   * 
   */
  public function setMaxFilesize($filesize) {
    $max = $this->strToBytes($filesize);
    $phpMax = $this->strToBytes(ini_get('upload_max_filesize'));
    if($phpMax < $max) $max = $phpMax;  
    $this->maxFilesize = $max; 
    return $this;
  }

  /**
   * Convert string like "32M" to bytes (integer)
   * 
   * @param string|int $filesize
   * @return int
   * 
   */
  protected function strToBytes($filesize) {
    if(ctype_digit("$filesize")) {
      $bytes = (int) $filesize;
    } else {
      $filesize = rtrim($filesize, 'bB'); // convert mb=>m, gb=>g, kb=>k
      $last = strtolower(substr($filesize, -1));
      if(ctype_alpha($last)) $filesize = rtrim($filesize, $last);
      $filesize = (int) $filesize;
      if($last == 'g') {
        $bytes = (($filesize * 1024) * 1024) * 1024;
      } else if($last == 'm') {
        $bytes = ($filesize * 1024) * 1024;
      } else if($last == 'k') {
        $bytes = $filesize * 1024;
      } else if($filesize > 0) {
        $bytes = $filesize;
      } else {
        $bytes = (5 * 1024) * 1024;
      }
    }
    return $bytes; 
  }

  /**
   * Per Inputfield interface, returns true when this field is empty
   *
   */
  public function isEmpty() {
    return !wireCount($this->value);
  }

  /**
   * Set an attribute
   * 
   * @param array|string $key
   * @param array|int|string $value
   * @return Inputfield|InputfieldFile
   * 
   */
  public function setAttribute($key, $value) {
    if($key == 'value') {
      if($value instanceof Pagefile) {
        // if given a Pagefile rather than a Pagefiles, use the Pagefiles instead
        $value = $value->pagefiles; 
      }
      if($value instanceof Pagefiles) {
        $page = $value->page;
        if($page && $page->template->noLang) $this->noLang = true;
      }
    }
    return parent::setAttribute($key, $value);
  }
  
  /**
   * Check to ensure that the containing form as an 'enctype' attr needed for uploading files
   *
   */
  protected function checkFormEnctype() {
    $parent = $this->parent;
    while($parent) {
      if($parent->attr('method') == 'post') {
        if(!$parent->attr('enctype')) $parent->attr('enctype', 'multipart/form-data');
        break;
      }
      $parent = $parent->parent; 
    }
  }

  /**
   * Set the parent of this Inputfield
   *
   * @param InputfieldWrapper $parent
   * @return $this
   *
   */
  public function setParent(InputfieldWrapper $parent) {
    parent::setParent($parent); 
    $this->checkFormEnctype();
    return $this;
  }

  /**
   * Get the unique 'id' attribute for the given Pagefile
   * 
   * @param Pagefile $pagefile
   * @param string $context Optional context string (like for repeaters) 3.0.178+
   * @return string
   * 
   */
  protected function pagefileId(Pagefile $pagefile, $context = '') {
    return $this->name . "_" . $context . $pagefile->hash; 
  }

  /**
   * Render a description input for the given Pagefile
   * 
   * @param Pagefile $pagefile
   * @param string $id 
   * @param int $n
   * @return string
   * 
   */
  protected function renderItemDescriptionField(Pagefile $pagefile, $id, $n) {
  
    if($n) {}
    $out = '';
    $tabs = '';
    static $hasLangTabs = null;
    static $langTabSettings = array();

    if($this->renderValueMode) {
      if($this->wire('languages')) {
        $description = $pagefile->description($this->wire('user')->language);
      } else {
        $description = $pagefile->description;
      }
      if(strlen($description)) $description = 
        "<div class='InputfieldFileDescription detail'>" . $this->wire('sanitizer')->entities1($description) . "</div>";
      return $description;
    }
    
    if($this->descriptionRows > 0) {

      $userLanguage = $this->wire('user')->language;
      $languages = $this->noLang ? null : $this->wire('languages');
      $defaultDescriptionFieldLabel = $this->wire('sanitizer')->entities1($this->labels['description']);

      if(!$userLanguage || !$languages || $languages->count() < 2) {
        $numLanguages = 0;
        $languages = array(null);
      } else {
        $numLanguages = $languages->count();
        if(is_null($hasLangTabs)) {
          $hasLangTabs = $this->wire('modules')->isInstalled('LanguageTabs');
          if($hasLangTabs) {
            /** @var LanguageTabs $languageTabs */
            $languageTabs = $this->wire('modules')->getModule('LanguageTabs');
            $langTabSettings = $languageTabs->getSettings();
          }
        }
      }

      foreach($languages as $language) {

        $descriptionFieldName = "description_$id";
        $descriptionFieldLabel = $defaultDescriptionFieldLabel;
        $labelClass = "detail";
        $attrStr = '';

        if($language) {
          $tabField = empty($langTabSettings['tabField']) ? 'title' : $langTabSettings['tabField'];
          $descriptionFieldLabel = (string) $language->getUnformatted($tabField);
          if(empty($descriptionFieldLabel)) $descriptionFieldLabel = $language->get('name');
          $descriptionFieldLabel = $this->wire('sanitizer')->entities($descriptionFieldLabel);
          if(!$language->isDefault()) $descriptionFieldName = "description{$language->id}_$id";
          $labelClass .= ' LanguageSupportLabel';
          if(!$languages->editable($language)) {
            $labelClass .= ' LanguageNotEditable';
            $descriptionFieldLabel = "<s>$descriptionFieldLabel</s>";
          }
          $tabID = "langTab_{$id}__$language";
          $aClass = "langTab$language";
          if(!empty($langTabSettings['aClass'])) $aClass .= " " . $langTabSettings['aClass'];
          $tabs .= "<li><a data-lang='$language' class='$aClass' href='#$tabID'>$descriptionFieldLabel</a></li>";
          $out .= "<div class='InputfieldFileDescription LanguageSupport' data-language='$language' id='$tabID'>"; // open wrapper
        } else {
          $out .= "<div class='InputfieldFileDescription'>"; // open wrapper
          $attrStr = "placeholder='$descriptionFieldLabel&hellip;'";
          $labelClass = 'detail pw-hidden';
        }
        
        $attrStr = "name='$descriptionFieldName' id='$descriptionFieldName' $attrStr";
        
        $out .= "<label for='$descriptionFieldName' class='$labelClass'>$descriptionFieldLabel</label>";

        $description = $this->wire('sanitizer')->entities($pagefile->description($language));

        if($this->descriptionRows > 1) {
          $out .= "<textarea $attrStr rows='$this->descriptionRows'>$description</textarea>";
        } else {
          $out .= "<input type='text' $attrStr value='$description' />";
        }

        $out .= "</div>"; // close wrapper
      }
      
      if($numLanguages && $hasLangTabs) {
        $ulClass = empty($langTabSettings['ulClass']) ? '' : " class='$langTabSettings[ulClass]'";
        $ulAttr = empty($langTabSettings['ulAttrs']) ? '' : " $langTabSettings[ulAttrs]";
        $out = "<div class='hasLangTabs langTabsContainer'><div class='langTabs'><ul $ulAttr$ulClass>$tabs</ul>$out</div></div>";
        if($this->isAjax) $out .= "<script>setupLanguageTabs($('#wrap_" . $this->attr('id') . "'));</script>";
      }

    }

    if($this->useTags) $out .= $this->renderItemTagsField($pagefile, $id, $n); 

    return $out;
  }

  /**
   * Render the tags input for the given Pagefile
   * 
   * @param Pagefile $pagefile
   * @param string $id
   * @param int $n
   * @return string
   * 
   */
  protected function renderItemTagsField(Pagefile $pagefile, $id, $n) {

    if($n) {}
    $tagsLabel = $this->wire('sanitizer')->entities($this->labels['tags']) . '&hellip;';
    $tagsStr = $this->wire('sanitizer')->entities($pagefile->tags);
    $tagsAttr = '';

    if($this->useTags >= FieldtypeFile::useTagsPredefined) {
      // select predefined
      $tagsClass = 'InputfieldFileTagsSelect';
      $tagsAttr = "data-cfgname='InputfieldFileTags_{$this->hasField->name}' ";

    } else {
      // text input
      $tagsClass = 'InputfieldFileTagsInput';
    }

    $out = 
      "<div class='InputfieldFileTags'>" . 
        "<label for='tags_$id' class='detail pw-hidden'>$tagsLabel</label>" . 
        "<input type='text' name='tags_$id' id='tags_$id' value='$tagsStr' " . 
          "placeholder='$tagsLabel' class='$tagsClass' $tagsAttr/>" . 
      "</div>";
    
    return $out;
  }

  /**
   * Get a basename for the file, possibly shortened, suitable for display in InputfieldFileList
   * 
   * @param Pagefile $pagefile
   * @param int $maxLength
   * @return string
   * 
   */
  public function getDisplayBasename(Pagefile $pagefile, $maxLength = 25) {
    $displayName = $pagefile->basename;
    if($this->noShortName) return $displayName;
    if(strlen($displayName) > $maxLength) {
      $ext = ".$pagefile->ext";
      $maxLength -= (strlen($ext) + 1);
      $displayName = basename($displayName, $ext);
      $displayName = substr($displayName, 0, $maxLength);
      $displayName .= "&hellip;" . ltrim($ext, '.');
    }
    return $displayName;  
  }

  /**
   * Render markup for a file item
   * 
   * @param Pagefile $pagefile
   * @param string $id
   * @param int $n
   * @return string
   * 
   */
  protected function ___renderItem($pagefile, $id, $n) {
  
    $displayName = $this->getDisplayBasename($pagefile);
    $deleteLabel = $this->labels['delete'];
  
    $out = 
      "<p class='InputfieldFileInfo InputfieldItemHeader ui-state-default ui-widget-header'>" . 
      wireIconMarkupFile($pagefile->basename, "fa-fw HideIfEmpty") . '&nbsp;' . 
      "<a class='InputfieldFileName' title='$pagefile->basename' target='_blank' href='{$pagefile->url}'>$displayName</a> " . 
      "<span class='InputfieldFileStats'>" . str_replace(' ', '&nbsp;', $pagefile->filesizeStr) . "</span> ";
    
    if(!$this->renderValueMode) $out .=
      "<label class='InputfieldFileDelete'>" . 
        "<input type='checkbox' name='delete_$id' value='1' title='$deleteLabel' />" . 
        "<i class='fa fa-fw fa-trash'></i></label>";
    
    $description = $this->renderItemDescriptionField($pagefile, $id, $n);
    $class = 'InputfieldFileData ';
    $class .= $description ? 'description ui-widget-content' : 'InputfieldFileFields';
    
    $out .= "</p><div class='$class'>" . $description;

    $inputfields = $this->getItemInputfields($pagefile);
    if($inputfields) $out .= $inputfields->render();
    
    if(!$this->renderValueMode) {
      $out .= "<input class='InputfieldFileSort' type='text' name='sort_$id' value='$n' />";
    }
    
    $out .= "</div>";
    
    return $out; 
  }

  /**
   * Wrap output of files list item
   * 
   * @param string $out
   * @return string
   * 
   */
  protected function renderItemWrap($out) {
    // note: using currentItem rather than a new argument since there are now a few modules extending
    // this one and if they implement their own calls to this method or version of this method then 
    // they will get strict notices from php if we add a new argument here. 
    $item = $this->currentItem; 
    $id = $item && !$this->renderValueMode ? " id='file_$item->hash'" : "";
    return "<li$id class='{$this->itemClass}'>$out</li>";
  }

  /**
   * Render files list ready
   * 
   * @param Pagefiles|null $value
   * @throws WireException
   * @throws WirePermissionException
   * 
   */
  protected function renderListReady($value) {
    if(!$this->renderValueMode) {
      // if just rendering the files list (as opposed to saving it), delete any temp files that may have accumulated
      if(!$this->overwrite && !count($_POST) && !$this->isAjax && !$this->uploadOnlyMode) {
        // don't delete files when in render single field or fields mode
        if(!$this->wire('input')->get('field') && !$this->wire('input')->get('fields')) {
          if($value instanceof Pagefiles) $value->deleteAllTemp();
        }
      }
    }
  }

  /**
   * Render files list
   * 
   * @param Pagefiles|null $value
   * @return string
   * 
   */
  protected function ___renderList($value) {

    if(!$value) return '';
    $out = '';
    $n = 0; 
  
    $this->renderListReady($value);

    if(!$this->uploadOnlyMode && WireArray::iterable($value)) {
      foreach($value as $k => $pagefile) {
        $id = $this->pagefileId($pagefile);
        $this->currentItem = $pagefile;
        $out .= $this->renderItemWrap($this->renderItem($pagefile, $id, $n++));
      }
    }

    $class = 'InputfieldFileList ui-helper-clearfix';
    if($this->overwrite && !$this->renderValueMode) $class .= " InputfieldFileOverwrite";
    if($out) $out = "<ul class='$class'>$out</ul>";
    
    return $out; 
  }

  /**
   * Render upload area
   * 
   * @param Pagefiles|null $value
   * @return string
   * 
   */
  protected function ___renderUpload($value) {
    if($value) {}
    if($this->noUpload || $this->renderValueMode) return '';

    // enables user to choose more than one file
    if($this->maxFiles != 1) $this->setAttribute('multiple', 'multiple'); 

    $attrs = $this->getAttributes();
    unset($attrs['value']); 
    if(substr($attrs['name'], -1) != ']') $attrs['name'] .= '[]';

    $extensions = $this->getAllowedExtensions();
    $formatExtensions = $this->formatExtensions();
    $chooseLabel = $this->labels['choose-file'];
    $dragDropLabel = $this->labels['drag-drop'];
    $attrStr = $this->getAttributesString($attrs);

    $out =
      "<div " .
        "data-maxfilesize='$this->maxFilesize' " .
        "data-extensions='$extensions' " .
        "data-fieldname='$attrs[name]' " .
        "class='InputfieldFileUpload'>
        ";
  
    if($this->getSetting('noCustomButton')) {
      $out .= "<input $attrStr>";
      
    } else {
      $out .= "
          <div class='InputMask ui-button ui-state-default'>
            <span class='ui-button-text'>
              <i class='fa fa-fw fa-folder-open-o'></i>$chooseLabel
            </span>
            <input $attrStr>
          </div>
          ";
    }
    
    $out .= "     
        <span class='InputfieldFileValidExtensions detail'>$formatExtensions</span>
        <input type='hidden' class='InputfieldFileMaxFiles' value='$this->maxFiles' />
      ";
    
    if(!$this->noAjax) $out .= "
        <span class='AjaxUploadDropHere description'>
          <span>
            <i class='fa fa-cloud-upload'></i>&nbsp;$dragDropLabel
          </span>
        </span>
      ";  
    
    $out .= "</div>"; // .InputfieldFileUpload

    return $out; 
  }

  /**
   * Render ready
   * 
   * @param Inputfield|null $parent
   * @param bool $renderValueMode
   * @return bool
   * 
   */
  public function renderReady(Inputfield $parent = null, $renderValueMode = false) {
    
    /** @var Config $config */
    $config = $this->wire('config');
    
    $this->addClass('InputfieldNoFocus', 'wrapClass');
    if(!$renderValueMode) $this->addClass('InputfieldHasUpload', 'wrapClass');
    
    if($this->useTags) {
      $this->wire('modules')->get('JqueryUI')->use('selectize');
      $this->addClass('InputfieldFileHasTags', 'wrapClass');
      if($this->useTags >= FieldtypeFile::useTagsPredefined && $this->hasField) {
        // predefined tags
        $fieldName = $this->hasField->name;
        $jsName = "InputfieldFileTags_$fieldName";
        $allowUserTags = $this->useTags & FieldtypeFile::useTagsNormal;
        $data = $config->js($jsName);
        if(!is_array($data)) $data = array();
        if(empty($data['tags'])) {
          $tags = array();
          foreach(explode(' ', (string) $this->get('tagsList')) as $tag) {
            $tag = trim($tag);
            if(!strlen($tag)) continue;
            $tags[strtolower($tag)] = $tag;
          }
          if($allowUserTags) {
            $pagefiles = $this->val();
            if($pagefiles instanceof Pagefiles) {
              $_tags = $pagefiles->tags(true);
              if(count($_tags)) $tags = array_merge($tags, $_tags);
            }
          }
          $data['tags'] = array_values($tags);
          $data['allowUserTags'] = $allowUserTags;
          $config->js($jsName, $data); 
        }
        $this->wrapAttr('data-configName', $jsName);
      } else {
        // regular tags text input
      }
    }
    
    $data = $config->js('InputfieldFile');
    if(!is_array($data)) $data = array();
    if(empty($data['labels'])) $data['labels'] = array();
    if(empty($data['labels']['bad-ext'])) {
      $data['labels']['bad-ext'] = $this->_('Unsupported file extension, please use only: EXTENSIONS');
      $data['labels']['too-big'] = $this->_('File is too big - maximum allowed size is MAX_KB kb');
      $config->js('InputfieldFile', $data); 
    }
    
    $this->getItemInputfields(); // custom fields ready
    
    return parent::renderReady($parent, $renderValueMode); 
  }

  /**
   * Render Inputfield input
   * 
   * @return string
   * 
   */
  public function ___render() {
    if(!$this->extensions) $this->error($this->_('No file extensions are defined for this field.')); 
    $numItems = wireCount($this->value);
    if($this->allowCollapsedItems()) $this->addClass('InputfieldItemListCollapse', 'wrapClass');
    if($numItems == 0) {
      $this->addClass('InputfieldFileEmpty', 'wrapClass');
    } else if($numItems == 1) {
      $this->addClass('InputfieldFileSingle', 'wrapClass');
    } else {
      $this->addClass('InputfieldFileMultiple', 'wrapClass');
    }
    return $this->renderList($this->value) . $this->renderUpload($this->value);
  }

  /**
   * Render Inputfield value
   * 
   * @return string
   * 
   */
  public function ___renderValue() {
    $this->renderValueMode = true; 
    $out = $this->render();
    $this->renderValueMode = false;
    return $out; 
  }

  /**
   * File added hook
   * 
   * @param Pagefile $pagefile
   * @throws WireException
   * 
   */
  protected function ___fileAdded(Pagefile $pagefile) {
    if($this->noUpload) return;
    $sanitizer = $this->wire()->sanitizer;
    
    $isValid = $sanitizer->validateFile($pagefile->filename(), array(
      'pagefile' => $pagefile
    ));
    
    if($isValid === false) {
      $errors = $sanitizer->errors('clear array');
      throw new WireException(
        "$pagefile->basename - " . $this->_('File failed validation') . 
        (count($errors) ? ": " . implode(', ', $errors) : "")
      );
    } else if($isValid === null) {
      // there was no validator available for this file type
    }

    $message = $this->_('Added file:') . " {$pagefile->basename}"; // Label that precedes an added filename

    if($this->isAjax && !$this->noAjax) {
      $n = count($this->value); 
      if($n) $n--; // for sorting
      $this->currentItem = $pagefile; 
      $markup = $this->fileAddedGetMarkup($pagefile, $n);
      $this->ajaxResponse(false, $message, $pagefile->url, $pagefile->filesize(), $markup); 
    } else {
      $this->message($message); 
    }
    
    $pagefile->createdUser = $this->wire('user');
    $pagefile->modifiedUser = $this->wire('user');
  }

  /**
   * Get markup for added file
   * 
   * @param Pagefile $pagefile
   * @param int $n
   * @return string
   * 
   */
  protected function fileAddedGetMarkup(Pagefile $pagefile, $n) {
    return $this->renderItemWrap($this->renderItem($pagefile, $this->pagefileId($pagefile), $n)); 
  }

  /**
   * Given a Pagefile return array of meta data pulled from it
   * 
   * @param Pagefile $pagefile
   * @param array $metadata Existing metadata, if applicable
   * @return array Associative array of meta data (i.e. description and tags)
   * 
   */
  protected function ___extractMetadata(Pagefile $pagefile, array $metadata = array()) {
    
    $metadata['description'] = $pagefile->description;
    
    /** @var Languages $languages */
    $languages = $this->wire('languages');
    if($languages && !$this->noLang) {
      foreach($languages as $language) {
        if($language->isDefault()) continue;
        $metadata["description$language->id"] = $pagefile->description($language);
      }
    }
    
    $metadata['tags'] = $pagefile->tags;
    $filedata = $pagefile->filedata();
    if(count($filedata)) $metadata['filedata'] = $filedata;
    
    return $metadata;
  }

  /**
   * Process input to add a file
   * 
   * @param string $filename
   * @throws WireException
   * 
   */
  protected function ___processInputAddFile($filename) {

    $total = count($this->value); 
    $metadata = array();
    $rm = null;

    if($this->maxFiles > 1 && $total >= $this->maxFiles) return; 
    
    // allow replacement of file if maxFiles is 1
    if($this->maxFiles == 1 && $total) {
      $pagefile = $this->value->first();
      $metadata = $this->extractMetadata($pagefile, $metadata);
      $rm = true; 
      if($filename == $pagefile->basename) {
        // use overwrite mode rather than replace mode when single file and same filename
        if($this->overwrite) $rm = false;
      }
      if($rm) {
        if($this->overwrite) $this->processInputDeleteFile($pagefile);
        $this->singleFileReplacement = true; 
      }
    } 
  
    if($this->overwrite) {
      $pagefile = $this->value->get($filename); 
      clearstatcache();
      if($pagefile) {
        // already have a file of the same name
        if($pagefile instanceof Pageimage) $pagefile->removeVariations(); 
        $metadata = $this->extractMetadata($pagefile, $metadata);
      } else {
        // we don't have a file with the same name as the one that was uploaded
        // file must be in another files field on the same page, that could be problematic
        $ul = $this->getWireUpload();
        // see if any files were overwritten that weren't part of our field
        // if so, we need to restore them and issue an error
        $err = false;
        foreach($ul->getOverwrittenFiles() as $bakFile => $newFile) {
          if(basename($newFile) != $filename) continue; 
          $this->wire('files')->unlink($newFile);   
          $this->wire('files')->rename($bakFile, $newFile); // restore
          $ul->error(sprintf($this->_('Refused file %s because it is already on the file system and owned by a different field.'), $filename)); 
          $err = true; 
        }
        if($err) return;
      }
    }

    $this->value->add($filename); 
    /** @var Pagefile $item */
    $item = $this->value->last();
    
    try {
      foreach($metadata as $key => $val) {
        if($val) $item->$key = $val;
      }
      // items saved in ajax or uploadOnly mode are temporary till saved in non-ajax/non-uploadOnly
      if($this->isAjax && !$this->overwrite) {
        if($this->wire('input')->get('InputfieldFileAjax') !== 'noTemp') {
          $item->isTemp(true);
        }
      }
      $this->fileAdded($item); 
    } catch(\Exception $e) {
      $item->unlink();
      $this->value->remove($item); 
      throw new WireException($e->getMessage()); 
    }
  }

  /**
   * Process input to delete a Pagefile item
   * 
   * @param Pagefile $pagefile
   * 
   */
  protected function ___processInputDeleteFile(Pagefile $pagefile) {
    $fileLabel = $this->wire('config')->debug ? $pagefile->url() : $pagefile->name;
    $this->message($this->_("Deleted file:") . " $fileLabel"); // Label that precedes a deleted filename
    $this->value->delete($pagefile); 
    $this->trackChange('value');
  }

  /**
   * Process input for one Pagefile
   * 
   * @param WireInputData $input
   * @param Pagefile $pagefile
   * @param int $n
   * @return bool
   * 
   */
  protected function ___processInputFile(WireInputData $input, Pagefile $pagefile, $n) {

    $saveFields = false; // allow custom Inputfields to be saved?
    $changed = false; // are there any changes to this file?
    $id = $this->name . '_' . $pagefile->hash;
  
    if($this->uploadOnlyMode) {
      // skip files that aren't present as just uploaded
      $key = "sort_$id";
      if($input->$key === null) return false;
    }
    
    // replace (currently only used by InputfieldImage)
    $key = "replace_$id";
    $replace = $input->$key;
    if($replace) {
      if(strpos($replace, '?') !== false) {
        list($replace, $unused) = explode('?', $replace);
        if($unused) {}
      }
      $replaceFile = $this->value->getFile($replace);
      if($replaceFile && $replaceFile instanceof Pagefile) {
        $this->processInputDeleteFile($replaceFile);
        if(strtolower($pagefile->ext()) == strtolower($replaceFile->ext())) {
          $this->value->rename($pagefile, $replaceFile->name);
        }
        $changed = true; 
      }
    }
  
    // rename (currently only used by InputfieldImage)
    $key = "rename_$id";
    $rename = (string) $input->$key;
    if(strlen($rename) && $rename != $pagefile->basename(false)) {
      $name = $pagefile->basename();
      $rename .= "." . $pagefile->ext();
      // cleanBasename($basename, $originalize = false, $allowDots = true, $translate = false) 
      $rename = $pagefile->pagefiles->cleanBasename($rename, true, true, true);
      if(strlen($rename)) {
        $message = sprintf($this->_('Renamed file "%1$s" to "%2$s"'), $name, $rename);
        if($pagefile->rename($rename) !== false) {
          $this->message($message);
          $changed = true;
        } else {
          $this->warning($this->_('Failed') . " - $message");
        }
      }
    }
  
    // description and tags
    $languages = $this->noLang ? null : $this->wire('languages');
    $keys = $languages ? array('tags') : array('description', 'tags'); 

    foreach($keys as $key) { 
      if(isset($input[$key . '_' . $id])) { 
        $value = $input[$key . '_' . $id]; 
        if(is_array($value)) $value = implode(' ', $value);
        $value = trim($value); 
        if($value != $pagefile->$key) {
          $pagefile->$key = $value; 
          $changed = true; 
        }
      }
    }

    // multi-language descriptions
    if($languages) foreach($languages as $language) {
      if(!$languages->editable($language)) continue; 
      $key = $language->isDefault() ? "description_$id" : "description{$language->id}_$id";
      if(!isset($input[$key])) continue; 
      $value = trim($input[$key]); 
      if($value != $pagefile->description($language)) {
        $pagefile->description($language, $value); 
        $changed = true; 
      }
    }
  
    if($this->uploadOnlyMode) {
      if($this->uploadOnlyMode === 2) {
        $sort = 0; // ensures an isTemp(false) call occurs below
      } else {
        $sort = null;
      }
      $changed = true;
    } else {
      $key = "sort_$id";
      $sort = $input->$key;
      if($sort !== null) {
        $sort = (int) $sort; 
        $pagefile->set('sort', $sort);
        if($n !== $sort) $changed = true;
        $saveFields = true;
      }
    }
    
    if($saveFields) {
      // save custom Inputfields
      $inputfields = $this->getItemInputfields($pagefile);
      if($inputfields && $this->processItemInputfields($pagefile, $inputfields, $id, $input)) $changed = true;
    }

    $delete = isset($input['delete_' . $id]) ? (int) $input['delete_' . $id] : 0;
    if(!empty($delete)) {
      $this->processInputDeleteFile($pagefile); 
      $changed = true; 
      
    } else if(!$this->isAjax && !$this->overwrite && $pagefile->isTemp() && $sort !== null) {
      // if page saved with temporary items when not ajax, those temporary items become non-temp
      $pagefile->isTemp(false);
      // @todo should the next statement instead be this below?
      // if($this->maxFiles > 0) while(count($this->value) > $this->>maxFiles) { ... } ?
      if($this->maxFiles == 1) while(count($this->value) > 1) {
        $item = $this->value->first();
        $this->value->remove($item);
      }
      $changed = true;
    }

    return $changed; 
  }

  /**
   * Process custom Inputfields for Pagefile item
   * 
   * @param Pagefile $pagefile
   * @param InputfieldWrapper $inputfields
   * @param string $id Pagefile ID string
   * @param WireInputData $input
   * @return bool True if changes detected, false if not
   * @since 3.0.142
   * 
   */
  protected function ___processItemInputfields(Pagefile $pagefile, InputfieldWrapper $inputfields, $id, WireInputData $input) {
    
    $changed = false;
    $inputfields->resetTrackChanges(true);
    $inputfields->processInput($input);
    
    foreach($inputfields->getAll() as $f) {
      /** @var Inputfield $f */
      foreach($f->getErrors(true) as $error) {
        $msg = "$this->label ($pagefile->name): $error";
        $this->error($msg);
        $f->error($msg);
      }
      if(!$f->isChanged() && !$pagefile->isTemp()) {
        continue;
      }
      $name = str_replace("_$id", '', $f->attr('name'));
      if($f->getSetting('useLanguages')) {
        $value = $pagefile->getFieldValue($name);
        if(is_object($value)) $value->setFromInputfield($f);
      } else {
        $value = $f->val();
      }
      $pagefile->setFieldValue($name, $value, true);
      $changed = true;
    }
    
    return $changed;
  }

  /**
   * Process input
   * 
   * @param WireInputData $input
   * @return self
   * 
   */
  public function ___processInput(WireInputData $input) {
    
    if(is_null($this->value)) $this->value = $this->wire(new Pagefiles($this->wire('page')));
    if(!$this->destinationPath) $this->destinationPath = $this->value->path();
    
    if(!$this->destinationPath || !is_dir($this->destinationPath)) {
      return $this->error($this->_("destinationPath is empty or does not exist"));
    }
    if(!is_writable($this->destinationPath)) {
      return $this->error($this->_("destinationPath is not writable"));
    }

    $changed = false; 
    $total = count($this->value); 

    if(!$this->noUpload) { 

      if($this->maxFiles <= 1 || $total < $this->maxFiles) { 

        $ul = $this->getWireUpload();
        $ul->setName($this->attr('name')); 
        $ul->setDestinationPath($this->destinationPath); 
        $ul->setOverwrite($this->overwrite); 
        $ul->setAllowAjax($this->noAjax ? false : true);
        if($this->maxFilesize) $ul->setMaxFileSize($this->maxFilesize); 

        if($this->maxFiles == 1) {
          $ul->setMaxFiles(1); 

        } else if($this->maxFiles) {
          $maxFiles = $this->maxFiles - $total; 
          $ul->setMaxFiles($maxFiles); 

        } else if($this->unzip) { 
          $ul->setExtractArchives(true); 
        }

        $ul->setValidExtensions($this->getAllowedExtensions(true));

        foreach($ul->execute() as $filename) {
          $this->processInputAddFile($filename); 
          $changed = true; 
        }

        if($this->isAjax && !$this->noAjax) foreach($ul->getErrors() as $error) { 
          $this->ajaxResponse(true, $error); 
        }

      } else if($this->maxFiles) {
        // over the limit
        $this->ajaxResponse(true, $this->_("Max file upload limit reached")); 
      }
    }

    $n = 0; 

    foreach($this->value as $pagefile) {
      if($this->processInputFile($input, $pagefile, $n)) $changed = true; 
      $n++; 
    }

    if($changed) {
      $this->value->sort('sort'); 
      $this->trackChange('value'); 
    }

    if(count($this->ajaxResponses) && $this->isAjax) {
      echo $this->renderAjaxResponse();
    }

    return $this; 
  }

  /**
   * Render JSON response to AJAX request
   * 
   * @return string
   * 
   */
  protected function renderAjaxResponse() {
    if($this->wire('input')->get('ckeupload')) {
      // https://docs.ckeditor.com/ckeditor4/docs/#!/guide/dev_file_upload
      $a = $this->ajaxResponses[0];
      $response = array(
        'uploaded' => $a['error'] ? 0 : 1,
        'fileName' => basename($a['file']),
        'url' => $a['file'],
        'ajaxResponse' => $a, // for InputfieldImage.js
      );
      if($a['error']) {
        $response['error'] = array(
          'message' => $a['message']
        );
      }
      return json_encode($response);
    } else {
      return json_encode($this->ajaxResponses);
    }
  }

  /**
   * Send an ajax response
   *
   * @param bool $error Whether it was successful
   * @param string $message Message you want to return
   * @param string $file Full path and filename or blank if not applicable
   * @param string $size 
   * @param string $markup
   *
   */
  protected function ajaxResponse($error, $message, $file = '', $size = '', $markup = '') {
    $response = array(
      'error' => $error, 
      'message' => $message, 
      'file' => $file,
      'size' => $size,
      'markup' => $markup, 
      'replace' => $this->singleFileReplacement,
      'overwrite' => $this->overwrite
      );

    $this->ajaxResponses[] = $response; 
  }

  /**
   * Return the current WireUpload instance or create a new one if not yet created
   *
   * @return WireUpload
   *
   */
  public function getWireUpload() {
    if(is_null($this->wireUpload)) $this->wireUpload = $this->wire(new WireUpload($this->attr('name'))); 
    return $this->wireUpload; 
  }

  /**
   * Template method: allow items to be collapsed?
   *
   * @return bool
   *
   */
  protected function allowCollapsedItems() {
    $allow = $this->descriptionRows == 0 && !$this->useTags && !$this->noCollapseItem;
    if($allow && $this->hasField) {
      /** @var FieldtypeFile $fieldtype */
      $fieldtype = $this->hasField->type;
      if($fieldtype->getFieldsTemplate($this->hasField)) $allow = false;
    }
    return $allow;
  }

  /**
   * Format list of file extensions for output with upload field
   *
   * @param array|string $extensions
   * @return string
   *
   */
  protected function formatExtensions($extensions = '') {
    $sanitizer = $this->wire()->sanitizer;
    $badExtensions = array();
    if(empty($extensions)) {
      $info = $this->getExtensionsInfo();
      $extensions = $info['valid'];
      $badExtensions = $info['invalid'];
    } else if(is_string($extensions)) {
      while(strpos($extensions, '  ') !== false) $extensions = str_replace('  ', ' ', $extensions);
      $extensions = explode(' ', trim($extensions)); 
    }
    $out = $sanitizer->entities(implode(', ', $extensions));
    if(count($badExtensions)) {
      if($out) $out .= ', ';
      $out .= '<s>' . $sanitizer->entities(implode(', ', $badExtensions)) . '</s>'; 
    }
    return $out;
  }
  
  /**
   * Get allowed file extensions
   * 
   * @param bool $getArray
   * @return array|string
   * @since 3.0.167
   * 
   */
  protected function getAllowedExtensions($getArray = false) {
    $info = $this->getExtensionsInfo();
    $extensions = $info['valid'];
    if($this->unzip && !$this->maxFiles) if(!in_array('zip', $extensions)) $extensions[] = 'zip';
    return $getArray ? $extensions : implode(' ', $extensions);
  }

  /**
   * Get extensions info (see FieldtypeFile::getValidFileExtensions)
   * 
   * @return array
   * @since 3.0.167
   * 
   */
  protected function getExtensionsInfo() {
    if(empty($this->extensionsInfo)) {
      $this->extensionsInfo = $this->wire()->fieldtypes->FieldtypeFile->getValidFileExtensions($this);
    }
    return $this->extensionsInfo;
  }

  /**
   * Get custom Inputfields for editing given Pagefile 
   * 
   * @param Pagefile|null $item Specify Pagefile item, or omit to prepare for render ready
   * @return bool|InputfieldWrapper
   * @since 3.0.142
   * 
   */
  public function getItemInputfields(Pagefile $item = null) {
  
    /** @var Pagefiles $pagefiles */
    $value = $this->val();
    $pagefiles = $value instanceof Pagefile ? $value->pagefiles : $value;
    
    if(!$pagefiles instanceof Pagefiles) {
      if($this->hasPage && $this->hasField) {
        $value = $this->hasPage->get($this->hasField->name);
        $pagefiles = $value instanceof Pagefile ? $value->pagefiles : $value;
      }
      if(!$pagefiles instanceof Pagefiles) {
        // no value present on this Inputfield
        return false;
      }
    }
    
    if($this->itemFieldgroup === false) {
      // item fieldgroup already determined not in use
      return false;
    }
  
    if($this->itemFieldgroup === null) {
      // item fieldgroup not yet determined
      $this->itemFieldgroup = false;
      $template = $pagefiles->getFieldsTemplate();
      if(!$template) return false;
      $this->itemFieldgroup = $template->fieldgroup;
    }

    $context = '';
    $process = $this->wire()->process; 
    if($item && $process instanceof WirePageEditor) { 
      $contextPage = $process->getPage();
      if(wireInstanceOf($contextPage, 'RepeaterPage')) {
        $context = "repeater{$contextPage->id}_";
      }
    }

    /** @var Page $page */
    $page = $pagefiles->getFieldsPage();
    $id = $item ? ('_' . $this->pagefileId($item, $context)) : '';
    $inputfields = $this->itemFieldgroup->getPageInputfields($page, $id, '', false); 
    if(!$inputfields) return false;
    
    /** @var Languages|null $languages */
    $languages = $this->wire('languages');

    foreach($inputfields->getAll() as $f) {
      
      if(!$item) {
        // prepare inputfields for render rather than populating them
        $f->renderReady();
        continue;
      }
      
      /** @var Inputfield $f */
      $name = str_replace($id, '', $f->name);
      $value = $item ? $item->getFieldValue($name) : null; 
      if($value === null) continue;
      
      if($languages && $f->getSetting('useLanguages') && $value instanceof LanguagesValueInterface) {
        foreach($languages as $language) {
          $v = $value->getLanguageValue($language->id);
          if($language->isDefault()) $f->val($v);
          $f->set("value$language->id", $v);
        }
      } else if($f instanceof InputfieldCheckbox) {
        if($value) $f->attr('checked', 'checked'); 
      } else {
        $f->val($value);
      }
      
      if($f->className() === 'InputfieldCKEditor') {
        // CKE does not like being placed in file/image fields.
        // I'm sure it's possible, but needs more work and debugging, so it's disabled for now.
        $allow = false;
      } else {
        $allow = true;
      }
      
      if(!$allow) {
        $inputfields->remove($f);
        $this->prependMarkup =
          "<p class='ui-state-error-text'>" .
          sprintf($this->_('Field “%1$s” type “%2$s” is not supported in field “%3$s”'), $f->label, $f->className(), $this->label) .
          '</p>';
        $f->getParent()->remove($f);
      }
    }
    
    return $inputfields;
  }

  /**
   * Configuration settings for InputfieldFile
   * 
   * @return InputfieldWrapper
   * 
   */
  public function ___getConfigInputfields() {
    $inputfields = parent::___getConfigInputfields();
    require_once($this->wire()->config->paths('InputfieldFile') . 'config.php');
    $configuration = new InputfieldFileConfiguration();
    $this->wire($configuration);
    $configuration->getConfigInputfields($this, $inputfields);
    return $inputfields;  
  }
}