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 foundif($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 rowif(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-isif(!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 filedataforeach($fieldValues as $key => $value) {$field = $fieldgroup->getFieldContext($key);if(!$field) continue;$idKey = "_$field->id";if($value === null) {// null to remove valueunset($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 fieldgroupforeach($filedata as $key => $value) {if(isset($idKeys[$key])) continue; // valid, skipif(strpos($key, '_') !== 0) continue; // some other filedata, skip$fieldID = ltrim($key, '_');if(!ctype_digit($fieldID)) continue; // not a custom field, skipif($fieldgroup->hasField((int) $fieldID)) continue; // valid, skipunset($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 stringif(!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/defaultreturn $value;}$description = array();// ensure value present for every language, even if blankforeach($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 anythingbreak;case self::outputFormatSingle:$value = $isEmpty ? null : $value->first();break;case self::outputFormatString:$value = $this->formatValueString($page, $field, $value);break;default: // outputFormatAutoif($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 dateif($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 matchif($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 tablereturn $fileSchema;}}// Filesize update (3.0.154): Adds 'filesize', 'created_users_id', 'modified_users_id' columnsif(!$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' columnif(!$hasFiledata && $this->addColumn($field, 'filedata', $schema)) {$fileSchema = $fileSchema | self::fileSchemaFiledata;}// Date update: Adds 'modified', 'created' columnsif(!$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 fieldif($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 templateforeach($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 calledreturn 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 insteadreturn "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 savingreturn $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 FileValidatorif(!in_array($ext, $validateExtensions)) continue;// if extension was manually whitelisted, then accept it as validif(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 listunset($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);}}