Blame | Last modification | View Log | Download
<?php namespace ProcessWire;/*** ProcessWire Page Fieldtype** Field that stories references to one or more ProcessWire pages.** For documentation about the fields used in this class, please see:* /wire/core/Fieldtype.php* /wire/core/FieldtypeMulti.php** ProcessWire 3.x, Copyright 2018 by Ryan Cramer* https://processwire.com**/class FieldtypePage extends FieldtypeMulti implements Module, ConfigurableModule {public static function getModuleInfo() {return array('title' => 'Page Reference','version' => 105,'summary' => 'Field that stores one or more references to ProcessWire pages','permanent' => true,);}const derefAsPageArray = 0;const derefAsPageOrFalse = 1;const derefAsPageOrNullPage = 2;/*** Subfield names that will match to the 'pages' table, rather than custom fields**/protected $nativeNames = array('name','status','template','templates_id','parent','parent_id','created','modified','published',);/*** Setup a hook to Pages::delete so that we can remove references when pages are deleted**/public function init() {$pages = $this->wire('pages');if($pages) {$pages->addHookAfter('delete', $this, 'hookPagesDelete');} else {$this->addHookAfter('Pages::delete', $this, 'hookPagesDelete');}}/*** FieldtypePage instances are only compatible with other FieldtypePage derived classes.** @param Field $field* @return Fieldtypes**/public function ___getCompatibleFieldtypes(Field $field) {$fieldtypes = parent::___getCompatibleFieldtypes($field);foreach($fieldtypes as $type) if(!$type instanceof FieldtypePage) $fieldtypes->remove($type);return $fieldtypes;}/*** Delete any records that are referencing the page that was deleted** @param HookEvent $event**/public function hookPagesDelete($event) {if(!$event->return) return; // if delete failed, then don't continue$page_id = $event->arguments[0]->id;$database = $this->wire('database');foreach($this->wire('fields') as $field) {if(!$field->type instanceof FieldtypePage) continue;$table = $database->escapeTable($field->table);// delete references to this page$query = $database->prepare("DELETE FROM `$table` WHERE data=:page_id");$query->bindValue(":page_id", $page_id, \PDO::PARAM_INT);$query->execute();// delete references this page is keeping to other pages$query = $database->prepare("DELETE FROM `$table` WHERE pages_id=:page_id");$query->bindValue(":page_id", $page_id, \PDO::PARAM_INT);$query->execute();}}/*** We want FieldtypePage to autoload so that it can monitor page deletes** @return bool**/public function isAutoload() {return true;}/*** Return an InputfieldPage of the type configured** @param Page $page* @param Field $field* @return InputfieldPage**/public function getInputfield(Page $page, Field $field) {$inputfield = $this->wire('modules')->get("InputfieldPage");$inputfield->class = $this->className();return $inputfield;}/*** 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 string|int|array|object $value**/public function ___wakeupValue(Page $page, Field $field, $value) {if($field->hasContext($page)) $field = $field->getContext($page);$template = null;$template_ids = self::getTemplateIDs($field);$derefAsPage = $field->get('derefAsPage');$allowUnpub = $field->get('allowUnpub');if(count($template_ids) == 1) {// we only use $template optimization if only one template selected$template = $this->wire('templates')->get(reset($template_ids));}// handle $value if it's blank, Page, or PageArrayif($derefAsPage > 0) {// value will ultimately be a single Pageif(!$value) return $this->getBlankValue($page, $field);// if it's already a Page, then we're good: return itif($value instanceof Page) return $value;// if it's a PageArray and should be a Page, determine what happens nextif($value instanceof PageArray) {// if there's a Page in there, return the first oneif(count($value) > 0) return $value->first();// it's an empty array, so return whatever our default isreturn $this->getBlankValue($page, $field);}} else {// value will ultimately be multiple pages// if it's already a PageArray, great, just return itif($value instanceof PageArray) return $value;// setup our default/blank value$pageArray = $this->getBlankValue($page, $field);// if $value is blank, then return our default/blank valueif(empty($value)) return $pageArray;}// if we made it this far, then we know that the value was not empty// so it's going to need to be populated from one type to the target type// we're going to be dealing with $value as an array this point forward// this is for compatibility with the Pages::getById functionif(!is_array($value)) $value = array($value);// $value = $this->validatePageIDs($page, $value);if(isset($value['_filters'])) {$filters = $value['_filters'];unset($value['_filters']);if(!count($filters)) $filters = null;} else {$filters = null;}if($derefAsPage > 0) {// we're going to return a single page, NullPage or false$pg = false;if(count($value)) {// get the first value in a PageArray, using $template and parent for optimization$pageArray = $this->wire('pages')->getById(array((int) reset($value)), $template);if(count($pageArray)) $pg = $pageArray->first();}if($pg && $pg->status >= Page::statusUnpublished && !$allowUnpub) $pg = false;if(!$pg) $pg = $this->getBlankValue($page, $field);return $pg;} else {// we're going to return a PageArrayif(!count($value)) return $this->getBlankValue($page, $field);if($filters) {$filters->add(new SelectorEqual('id', $value));$finder = $this->pages->getPageFinder();$value = $finder->findIDs($filters);}$pageArray = $this->wire('pages')->getById($value, $template);foreach($pageArray as $pg) {// remove any pages that have an unpublished statusif($pg->status >= Page::statusUnpublished && !$allowUnpub) $pageArray->remove($pg);}$pageArray->resetTrackChanges();return $pageArray;}}/*** Pre-validate the given page IDs** @param Page $page* @param array $ids* @return arrayprotected function validatePageIDs(Page $page, array $ids) {foreach($ids as $key => $id) {// ensure no circular referenceif($id == $page->id) unset($ids[$key]);}return $ids;}*//*** 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) {if($field->hasContext($page)) $field = $field->getContext($page);$sleepValue = array();if($field->get('derefAsPage') > 0) {// if the $value isn't specifically a Page, make it a blank array for storageif(!$value instanceof Page || !$value->id) return $sleepValue;// if $value is a Page (not a NullPage) then place it's ID in an array for storage$this->isValidPage($value, $field, $page, true);$sleepValue[] = $value->id;} else {// if $value isn't a PageArray then we'll store a blank arrayif(!$value instanceof PageArray) return $sleepValue;// iterate through the array and place each Page IDforeach($value as $pg) {if(!$pg->id) continue;$this->isValidPage($pg, $field, $page, true);$sleepValue[] = $pg->id;}}return $sleepValue;}/*** Export value** @param Page $page* @param Field $field* @param array|int|object|string $value* @param array $options* @return array|string**/public function ___exportValue(Page $page, Field $field, $value, array $options = array()) {if($value instanceof Page) return $this->exportValuePage($page, $field, $value, $options);if(!$value instanceof PageArray) return array();$a = array();foreach($value as $k => $v) {$a[] = $this->exportValuePage($page, $field, $v, $options);}// in human mode just return the titles separated by a carriage returnif(!empty($options['human'])) return implode("\n", $a);return $a;}protected function exportValuePage(Page $page, Field $field, Page $value, array $options = array()) {if($page) {}if($field) {}if(!$value->id) return array();// in human mode, just return the title or nameif(!empty($options['human'])) {return (string) $value->get('title|name');}// otherwise return an array of info$a = array();if(!empty($options['system'])) {$a = $value->path;} else {if($value->template && $value->template->fieldgroup->has('title')) {$a['title'] = (string) $value->getUnformatted('title');}$a['id'] = $value->id;$a['name'] = $value->name;$a['path'] = $value->path;$a['template'] = (string) $value->template;$a['parent_id'] = $value->parent_id;}return $a;}/*** Import value** @param Page $page* @param Field $field* @param array|string $value* @param array $options* @return PageArray**/public function ___importValue(Page $page, Field $field, $value, array $options = array()) {$pageArray = $this->wire('pages')->newPageArray();if(empty($value)) return $pageArray;if(is_string($value)) $value = array($value);foreach($value as $item) {if(is_array($item)) {$path = $item['path'];} else if(is_string($item)) {// system option$path = $item;} else {continue;}$p = $this->wire('pages')->get($path);if(!$p->id) {$pageArray->error("Unable to find page '$path' to add to field '$field->name'");} else if(!$this->isValidPage($p, $field, $page)) {$reason = $page->get('_isValidPage');$warning = "Page '$p->path' is not allowed by field '$field->name' " . ($reason ? "($reason)" : "");$pageArray->error($warning);} else {$pageArray->add($p);}}return $pageArray;}/*** Get associative array of options (name => default) that Fieldtype supports for importValue** #pw-internal** @param Field $field* @return array**/public function getImportValueOptions(Field $field) {$options = parent::getImportValueOptions($field);$options['test'] = true;return $options;}/*** Format the given value for output.** In this case, we remove non-listable (unpublished) pages when necessary.** @param Page $page* @param Field $field* @param string|int|WireArray|object $value* @return string**/public function ___formatValue(Page $page, Field $field, $value) {// remove unpublished pages for front-end formatted outputif($value instanceof Page) {if($value->hasStatus(Page::statusUnpublished)) {$value = $this->getBlankValue($page, $field);}} else if($value instanceof PageArray && $field->get('allowUnpub')) {// unpublished pages are allowed, so if any are present, create new// formatted value that excludes the unpublished pages$hasUnpublished = false;foreach($value as $item) {$hasUnpublished = $item->hasStatus(Page::statusUnpublished);if($hasUnpublished) break;}if($hasUnpublished) {$items = $this->getBlankValue($page, $field);$items->setTrackChanges(false);foreach($value as $item) {if(!$item->hasStatus(Page::statusUnpublished)) $items->add($item);}$items->resetTrackChanges(true);$value = $items;}}return $value;}/*** Render markup value for field** @param Page $page* @param Field $field* @param null $value* @param string $property* @return MarkupFieldtype|string**/public function ___markupValue(Page $page, Field $field, $value = null, $property = '') {$labelFieldName = $field->get('labelFieldName');$labelFieldFormat = $field->get('labelFieldFormat');if(is_null($value)) $value = $page->getFormatted($field->name);if($property === '.') $property = '';if(empty($property) && $labelFieldName) {if($labelFieldName === '.') {if(strlen($labelFieldFormat)) $property = $labelFieldFormat;else $property = 'name';} else {$property = $labelFieldName;}}return parent::___markupValue($page, $field, $value, $property);}/*** Return either a blank Page or a blank PageArray or boolean false (depending on derefAsPage setting)** @param Page $page* @param Field $field* @return Page|PageArray|bool**/public function getBlankValue(Page $page, Field $field) {$derefAsPage = $field->get('derefAsPage');if($derefAsPage == FieldtypePage::derefAsPageOrFalse) {// single page possible blank valuesreturn false;} else if($derefAsPage == FieldtypePage::derefAsPageOrNullPage) {// single page possible blank valuesreturn $this->wire('pages')->newNullPage();} else {// multi page blank value (FieldtypePage::derefAsPageArray)$pageArray = $this->wire('pages')->newPageArray();$pageArray->setTrackChanges(true);return $pageArray;}}/*** Given a string value return either a Page or PageArray** @param Page $page* @param Field $field* @param string $value* @return Page|PageArray**/protected function sanitizeValueString(Page $page, Field $field, $value) {$result = false;$parent_id = $field->get('parent_id');if(Selectors::stringHasOperator($value)) {// selector string$selector = $value;/** @var InputfieldPage $inputfield */$inputfield = $field->getInputfield($page);/** @var PageArray $selectablePages */$selectablePages = $inputfield->getSelectablePages($page);$result = $selectablePages->filter($selector);} else if(ctype_digit("$value")) {// page ID$result = $this->pages->get("id=" . $value);} else if(strpos($value, '-') === 0 && ctype_digit(ltrim($value, '-'))) {// page ID to remove from value$result = $this->pages->get("id=" . ltrim($value, '-'));$result->set('_FieldtypePage_remove', $result->id);} else if(strpos($value, '|') !== false && ctype_digit(str_replace('|', '', $value))) {// CSV string separated by '|' characters$result = $this->pages->getById(explode('|', $value));} else if(strlen($value) && $value[0] == '/') {// path to page$result = $this->pages->get($value);} else if(strpos($value, "\n") !== false || strpos($value, '|') !== false) {// multiple references in a newline or pipe separated string$values = str_replace(array("\r\n", "\r", "|"), "\n", $value);// string has been normalized to newline separated only$values = explode("\n", $values);foreach($values as $str) {$v = $this->sanitizeValueString($page, $field, trim($str)); // recursiveif($v && $v->id) {if(!$result) $result = $this->wire('pages')->newPageArray();$result->add($v);}}} else if($parent_id) {// set by title$value = trim($value);$parentIDs = is_array($parent_id) ? implode('|', $parent_id) : $parent_id;// find by title$pageTitle = $this->wire('sanitizer')->selectorValue($value);$result = $this->wire('pages')->get("parent_id=$parentIDs, title=$pageTitle");// if cannot find by title, find by nameif(!$result->id) {$pageName = $this->wire('sanitizer')->selectorValue($this->wire('sanitizer')->pageNameUTF8($value));$result = $this->wire('pages')->get("parent_id=$parentIDs, name=$pageName");}if(!$result->id && $field->get('_sanitizeValueString') === 'create' && $field->get('template_id')) {// option to create page if it does not already exist (useful for imports)// to use this, you must $field->set('_sanitizeValueString', 'create'); ahead of time$template = $this->wire('templates')->get((int) $field->get('template_id'));$parent = $this->wire('pages')->get((int) $parent_id);if($template && $parent->id) {$result = $this->wire('pages')->newPage($template);$result->parent = $parent;$result->title = $value;$result->name = $this->wire('sanitizer')->pageNameUTF8($value);$result->save(array('adjustName' => true));}}// here} else {$template_ids = self::getTemplateIDs($field, true);if(!empty($template_ids)) {// set by title$result = $this->wire('pages')->get("templates_id=$template_ids, title=" . $this->wire('sanitizer')->selectorValue($value));// set by nameif(!$result->id) $result = $this->wire('pages')->get("templates_id=$template_ids, name=" .$this->wire('sanitizer')->selectorValue($this->wire('sanitizer')->pageNameUTF8($value)));}}if(!$result && $this->wire('config')->debug) {$this->warning("Unable to locate page match for: $value");}return $result;}/*** Given a value of unknown type, return a Page or PageArray (depending on $field->derefAsPage setting)** @param Page $page* @param Field $field* @param Page|PageArray|string|int $value* @return Page|PageArray|bool Returns false if value can't be converted to the proper object type.**/public function sanitizeValue(Page $page, Field $field, $value) {if($field->get('derefAsPage') > 0) {// Page$value = $this->sanitizeValuePage($page, $field, $value);} else {// PageArray$value = $this->sanitizeValuePageArray($page, $field, $value);}return $value;}/*** Handles the sanitization of values when target is a single Page** @param Page $page* @param Field $field* @param Page|PageArray* @return Page|bool|null**/protected function sanitizeValuePage(Page $page, Field $field, $value) {if(!$value) return $this->getBlankValue($page, $field);if($value instanceof Page) {// ok} else if($value instanceof PageArray) {$value = $value->first();} else if(is_string($value) || is_int($value)) {$value = $this->sanitizeValueString($page, $field, $value);if($value instanceof PageArray) $value = $value->first();/** @var Page $value */if(is_object($value) && $value->get('_FieldtypePage_remove') === $value->id) {$value->__unset('_FieldtypePage_remove');$value = null; // remove item}}$value = (($value instanceof Page) && $value->id) ? $value : $this->getBlankValue($page, $field);if($value && $value->id && $page->id == $value->id) {$value = $this->getBlankValue($page, $field); // prevent circular references}return $value;}/*** Validate that that $value is a valid Page for this field** @param Page $value The value to validate* @param Field $field The field the value is for* @param Page $forPage The page the value will exist on* @param bool $throwException Whether to throw an exception when not valid (default=false)* @throws WireException* @return bool**/public function isValidPage(Page $value, Field $field, Page $forPage, $throwException = false) {if(InputfieldPage::isValidPage($value, $field, $forPage)) {$valid = true;} else {$n = 0;while(wireInstanceOf($forPage, 'RepeaterPage') && ++$n < 10) {/** @var RepeaterPage $forPage */$forPage = $forPage->getForPage();}if($n && InputfieldPage::isValidPage($value, $field, $forPage)) {$valid = true;} else {$valid = false;$reason = $forPage->get("_isValidPage");if($throwException) throw new WireException("Page $value is not valid for $field->name ($reason)");}}return $valid;}/*** Handles the sanitization of values when target is a PageArray and $value is undetermined** @param Page $page* @param Field $field* @param Page|PageArray $value* @return PageArray**/protected function sanitizeValuePageArray(Page $page, Field $field, $value) {// if they are setting it to a PageArray, then we'll take itif($value instanceof PageArray) {// double check there aren't circular referencesforeach($value as $v) if($v->id == $page->id) $value->remove($v);return $value;}// otherwise, lets get the current value so we can add to it or return it$pageArray = $page->get($field->name);// if no value was provided, then return the existing value already in the pageif(!$value) return $pageArray;// if it's a string, see if we can convert it to a Page or PageArrayif(is_string($value)) $value = $this->sanitizeValueString($page, $field, $value);// if it's a Page, and not NullPage, add it to the existing PageArrayif($value instanceof Page) {if($value->get('_FieldtypePage_remove') === $value->id) {$value->__unset('_FieldtypePage_remove');return $pageArray->remove($value); // remove}if($value->id && $value->id != $page->id) $pageArray->add($value);return $pageArray;}// if it's a new PageArray, combine it with the existing PageArrayif($value instanceof PageArray) {foreach($value as $pg) {if(!$pg->id || $pg->id == $page->id) continue;$pageArray->add($pg);}return $pageArray;}// value is an int or array of int|string|Page, load to pages, add to $pageArrayif(!is_array($value)) $value = array($value);foreach($value as $v) {if(is_object($v) && $v instanceof Page) {// Page objectif($v->id == $page->id) continue;$pg = $v;} else if(is_int($v)) {// integer page IDif($v == $page->id) continue;$pg = $this->wire('pages')->get($v);} else if(is_string($v)) {// path or selector stringif(ctype_digit($v)) {$v = (int) $v;if($v == $page->id) continue;}$pg = $this->wire('pages')->get($v);} else {// unrecognized type: can't make a page object from itcontinue;}if($pg->id && $pg->id != $page->id) $pageArray->add($pg);}return $pageArray;}/*** Apply a where condition to a load query (used by getLoadQuery method)** @param Field $field* @param DatabaseQuerySelect $query* @param string $col The column name* @param string $operator The comparison operator* @param mixed $value The value to find* @return DatabaseQuery $query**/protected function getLoadQueryWhere(Field $field, DatabaseQuerySelect $query, $col, $operator, $value) {// cancel the default behavior since Page fields filter from the wakeupValue method insteadreturn $query;}/*** Update a DatabaseQuerySelect object to match a Page** @param DatabaseQuerySelect|PageFinderDatabaseQuerySelect $query* @param string $table* @param string $subfield* @param string $operator* @param string $value* @return DatabaseQuery* @throws WireException**/public function getMatchQuery($query, $table, $subfield, $operator, $value) {$names = array('id','data','pages_id','path','url','sort',);$database = $this->wire('database');// when $idstr is true, indicates $value is a multi-value CSV ID string (converts to boolean once known)$idstr = null;// if subfield is 'data' (meaning no subfield specified) and it's in the format of 'some-string',// then we assume this to be a page nameif($subfield == 'data'&& !ctype_digit("$value")&& strlen($value)&& strpos($value, '/') === false&& strpos($value, 'page.') !== 0) {$idstr = strpos($value, ',') !== false && ctype_digit(str_replace(',', '', $value));if(!$idstr) $subfield = 'name';}// let the FieldtypeMulti base class handle count queriesif($subfield == 'count') {return parent::getMatchQuery($query, $table, $subfield, $operator, $value);} else if(in_array($subfield, $names) || ($subfield == 'name' && $operator == '!=')) {if(!$database->isOperator($operator)) {throw new WireException("Operator '$operator' is not implemented in {$this->className}");}if($subfield == 'name') {$value = $this->wire('sanitizer')->pageName($value);$value = implode(',', $this->pages->findIDs("name=$value, include=all"));if(empty($value)) $value = "0";$idstr = true;}if(in_array($subfield, array('id', 'path', 'url', 'name'))) $subfield = 'data';// if a page path rather than page ID was provided, then we translate the path to an ID for API syntax convenienceif($idstr || !ctype_digit("$value")) {if(is_null($idstr) && $subfield == 'data' && strpos($value, ',') !== false) {$idstr = ctype_digit(str_replace(',', '', $value));}if($subfield == 'data' && $idstr) {// CSV string of page IDs$value = trim($value, ",");$idstr = true;} else if(substr(trim($value), 0, 1) == '/') {// path from root$v = $this->pages->get($value);if($v && $v->id) $value = $v->id;}}$subfield = $database->escapeCol($subfield);if($operator == '!=') {$t = $database->escapeTable($query->field->getTable());$where = "SELECT COUNT(*) FROM $t WHERE $t.pages_id=pages.id ";if($idstr) {$value = explode(',', $value);$value = array_map('intval', $value);$value = implode(',', $value);$where .= "AND $t.$subfield IN($value)";} else {$bindKey = $query->bindValueGetKey($value);$where .= "AND $t.$subfield=$bindKey";}$query->where("($where)=0");} else if($operator == '=' && $idstr) {$value = explode(',', $value);$value = array_map('intval', $value);$value = implode(',', $value);$query->where("($table.{$subfield} IN($value))");} else {$bindKey = $query->bindValueGetKey($value);$query->where("($table.{$subfield}{$operator}$bindKey)");}} else if($this->getMatchQueryNative($query, $table, $subfield, $operator, $value)) {// great} else if($this->getMatchQueryCustom($query, $table, $subfield, $operator, $value)) {// wonderful as well} else {// we were unable to determine what subfield isthrow new WireException("Unknown subfield: $subfield");}return $query;}/*** Update a query object to match a Page with a subfield native to pages table** @param DatabaseQuerySelect $query* @param string $table* @param string $subfield* @param string $operator* @param string $value* @return bool True if used, false if not**/protected function getMatchQueryNative($query, $table, $subfield, $operator, $value) {$database = $this->wire('database');if(!in_array($subfield, $this->nativeNames)) return false;// we let the custom field query matcher handle the '!=' scenarioif(!$database->isOperator($operator)) return $this->getMatchQueryCustom($query, $table, $subfield, $operator, $value);if($subfield == 'created' || $subfield == 'modified' || $subfield == 'published') {if(!ctype_digit($value)) $value = strtotime($value);$value = (int) $value;$value = date('Y-m-d H:i:s', $value);} else if(in_array($subfield, array('template', 'templates_id'))) {$template = $this->templates->get($subfield);$value = $template ? $template->id : 0;$subfield = 'templates_id';} else if(in_array($subfield, array('parent', 'parent_id'))) {if(!ctype_digit("$value")) $value = $this->pages->get($value)->id;$subfield = 'parent_id';} else if($subfield == 'status') {$statuses = Page::getStatuses();if(ctype_digit("$value")) {$value = (int) $value;} else if(isset($statuses[$value])) {$value = $statuses[$value];} else $value = 0;} else if($subfield == 'name') {$value = $this->sanitizer->pageName($value, Sanitizer::toAscii);} else $value = (int) $value;static $n = 0;$table = $database->escapeTable($table);$table2 = "_fieldtypepage" . (++$n);$subfield = $database->escapeCol($subfield);$bindKey = $query->bindValueGetKey($value);$query->join("pages AS $table2 ON $table2.$subfield$operator$bindKey");$query->where("($table.data=$table2.id)");return true;}/*** Update a DatabaseSelectQuery object to match a Page containing a matching custom subfield** Note: $field->findPagesCode is not implemented and thus not supported here.** @param DatabaseQuerySelect $query* @param string $table* @param string $subfield* @param string $operator* @param string $value* @return bool true if used, false if not* @throws WireException if selector not supported**/protected function getMatchQueryCustom($query, $table, $subfield, $operator, $value) {$database = $this->wire('database');$field = $query->field;$group = $query->group;$table = $database->escapeTable($table);$selector = 'include=hidden, '; // @todo should $selector include check_access=0 or even include=all?$pageFinder = $this->wire(new PageFinder());$pageFinderOptions = array('getTotal' => false);$value = $this->wire('sanitizer')->selectorValue($value);$findPagesSelector = $field->get('findPagesSelector');if(empty($findPagesSelector)) $findPagesSelector = $field->get('findPagesSelect');$parent_id = $field->get('parent_id');$template_ids = self::getTemplateIDs($field, true);if(in_array($subfield, $this->nativeNames)) {// fine then, we can handle that here when needed (like !=)} else {$subfield = $this->wire('fields')->get($subfield);if(!$subfield) return false; // not a custom field$subfield = $subfield->name;}if($findPagesSelector) {// remove the existing include=hidden, only if selector specifies a different include=somethingif(strpos($findPagesSelector, 'include=') !== false) $selector = '';$selector .= $findPagesSelector . ", ";if(strpos($selector, 'page.') !== false) {// remove any page.property from selector as it won't be applicable during a getMatchQuery$selector = preg_replace('/\bpage\.[a-zA-Z0-9_]+\s*[=!]+\s*[^,]*,?/', '', $selector);$selector = preg_replace('/[a-zA-Z0-9_.]+\s*[=!]+\s*page\.[a-zA-Z0-9_]+\s*,?/', '', $selector);}}if($parent_id) $selector .= "parent_id=$parent_id, ";if($template_ids) $selector .= "templates_id=$template_ids, ";if(!is_null($group)) {// combine with other selectors sharing the same group so that both must match in our subquery belowforeach($query->selectors as $item) {if($item->group !== $group) continue;if($item === $query->selector) continue;$itemField = $item->field;if(is_array($itemField)) {if(count($itemField) > 1) throw new WireException("Selector not supported");$itemField = reset($itemField);}/** @noinspection PhpUnusedLocalVariableInspection */list($itemField, $itemSubfield) = explode('.', $itemField);if($itemField != $field->name) continue; // only group the same fields together in one selector queryif(!preg_match('/^' . $group . '@' . $field->name . '\.(([_a-zA-z0-9]+).*)$/', (string) $item, $matches)) continue;// extract the field name portion so we just get the subfield and rest of the selector$selector .= "$matches[1], ";}}if($operator == '!=') {$selector .= "$subfield=$value, ";$matches = $pageFinder->find(new Selectors(trim($selector, ', ')), $pageFinderOptions);if(count($matches)) {$ids = array();foreach($matches as $match) $ids[$match['id']] = (int) $match['id'];static $xcnt = 0;$fieldTable = $database->escapeTable($field->table);$t = 'x_' . $fieldTable . (++$xcnt);$query->leftjoin("$fieldTable AS $t ON $t.pages_id=pages.id AND $t.data IN(" . implode(',', $ids) . ")");$query->parentQuery->where("$t.data IS NULL");}} else {$selector .= "{$subfield}$operator$value, ";$selectors = $this->wire(new Selectors(trim($selector, ', ')));$matches = $pageFinder->find($selectors, $pageFinderOptions);// use the IDs found from the separate find() as our getMatchQueryif(count($matches)) {$ids = array();foreach($matches as $match) {$ids[$match['id']] = (int) $match['id'];}$query->where("$table.data IN(" . implode(',', $ids) . ")");} else {$query->where("1>2"); // forced non-match}}return true;}/*** Return the database schema in predefined format** @param Field $field* @return array**/public function getDatabaseSchema(Field $field) {$schema = parent::getDatabaseSchema($field);$schema['data'] = 'int NOT NULL';$schema['keys']['data'] = 'KEY data (data, pages_id, sort)';return $schema;}/*** Return array with information about what properties and operators can be used with this field** @param Field $field* @param array $data* @return array**/public function ___getSelectorInfo(Field $field, array $data = array()) {$info = parent::___getSelectorInfo($field, $data);if(!isset($data['level'])) $data['level'] = 0;$info['input'] = 'page';if($data['level'] > 0) return $info;$subfields = array();$fieldgroups = array();$template_ids = self::getTemplateIDs($field);$parent_id = $field->get('parent_id');if($template_ids) {// determine fieldgroup(s) from template setting// template_id can be int or array of intsforeach($template_ids as $tid) {$template = $this->wire('templates')->get((int) $tid);if($template) $fieldgroups[] = $template->fieldgroup;}} else if($parent_id) {// determine fieldgroup(s) from family settings$parent = $this->wire('pages')->get((int) $parent_id);if($parent->id) {foreach($parent->template->childTemplates as $template_id) {$template = $this->wire('templates')->get((int) $template_id);if(!$template) continue;$fieldgroups[$template->fieldgroup->id] = $template->fieldgroup;}foreach($this->wire('templates') as $template) {if(!in_array($parent->template->id, $template->parentTemplates)) continue;if(!$this->wire('pages')->count("parent=$parent_id, template=$template->id, include=all")) continue;$fieldgroups[$template->fieldgroup->id] = $template->fieldgroup;}}}if(!count($fieldgroups)) {// if no fieldgorups found, then we have no choice but to use all fields//$fieldgroups[] = $this->wire('fields');}foreach($fieldgroups as $fieldgroup) {foreach($fieldgroup as $f) {if(!$f->type) continue;if(strpos("$f->type", "FieldtypeFieldset") === 0) continue;if(isset($subfields[$f->name])) continue;$subfields[$f->name] = $f->type->getSelectorInfo($f, array('level' => $data['level']+1));}}$subfields['count'] = array('name' => 'count','label' => $this->_('Count'), // Label for 'count' property of a PageArray'input' => 'number',);$info['subfields'] = $subfields;return $info;}/*** Return configuration fields definable for each FieldtypePage** @param Field $field* @return InputfieldWrapper**/public function ___getConfigInputfields(Field $field) {$inputfields = parent::___getConfigInputfields($field);$labels = array('PageArray' => $this->_('Multiple pages (PageArray)'),'PageOrFalse' => $this->_('Single page (Page) or boolean false when none selected'),'PageOrNullPage' => $this->_('Single page (Page) or empty page (NullPage) when none selected'),);$_labels = $labels;$url = "https://processwire.com/api/ref";$aPageArray = "[PageArray]($url/page-array/)";$aPage = "[Page]($url/page/)";$aNullPage = "[NullPage]($url/null-page/)";foreach($labels as $key => $value) {$labels[$key] = str_replace(array('(PageArray)', '(Page)', '(NullPage)'),array("$aPageArray", "$aPage", "$aNullPage"),$value);}/** @var InputfieldRadios $select */$select = $this->modules->get("InputfieldRadios");$select->attr('name', 'derefAsPage');$select->label = $this->_('Page field value type');$select->description = $this->_('If your field will contain multiple pages, then you should select the first option (PageArray). If your field only needs to contain a single page, then select one of the single Page options (if you are not sure which, select the last option).'); // Long description for: dereference in API$select->addOption(FieldtypePage::derefAsPageArray, $labels['PageArray']);$select->addOption(FieldtypePage::derefAsPageOrFalse, $labels['PageOrFalse']);$select->addOption(FieldtypePage::derefAsPageOrNullPage, $labels['PageOrNullPage']);$select->attr('value', (int) $field->get('derefAsPage'));$select->icon = 'tasks';$inputfields->append($select);/** @var InputfieldMarkup $f */$exampleFieldset = $this->wire('modules')->get('InputfieldFieldset');$exampleFieldset->attr('name', '_examplesFieldset');$exampleFieldset->label = $this->_('API usage examples');$exampleFieldset->icon = 'code';$inputfields->add($exampleFieldset);$f = $this->wire('modules')->get('InputfieldMarkup');$f->attr('name', '_examplePageArray');$f->label = $_labels['PageArray'];$f->showIf = 'derefAsPage=' . FieldtypePage::derefAsPageArray;$f->icon = 'scissors';$f->value ="<pre class='language-php'><code>" . $this->wire('sanitizer')->entities("foreach(\$page->{$field->name} as \$item) {" ."\n echo \"<li><a href='\$item->url'>\$item->title</a></li>\";"."\n}") . "</code></pre>";$exampleFieldset->add($f);$alternateLabel = $this->_('Same as above with alternate syntax');$f = $this->wire('modules')->get('InputfieldMarkup');$f->attr('name', '_examplePageArray2');$f->label = $alternateLabel;$f->showIf = 'derefAsPage=' . FieldtypePage::derefAsPageArray;$f->icon = 'scissors';$f->value ="<pre class='language-php'><code>" . $this->wire('sanitizer')->entities("echo \$page->{$field->name}->each(\n \"<li><a href='{url}'>{title}</a></li>\"\n);") . "</code></pre>";$f->notes = sprintf($this->_('More about the %s method.'), "[each()]($url/page-array/each/)");$exampleFieldset->add($f);/** @var InputfieldMarkup $f */$f = $this->wire('modules')->get('InputfieldMarkup');$f->attr('name', '_examplePageOrFalse');$f->label = $_labels['PageOrFalse'];$f->showIf = 'derefAsPage=' . FieldtypePage::derefAsPageOrFalse;$f->icon = 'scissors';$f->value ="<pre class='language-php'><code>" . $this->wire('sanitizer')->entities("if(\$page->{$field->name}) {" ."\n echo \"<a href='{\$page->{$field->name}->url}'>{\$page->{$field->name}->title}</a>\";" ."\n}") . "</code></pre>";$exampleFieldset->add($f);/** @var InputfieldMarkup $f */$f = $this->wire('modules')->get('InputfieldMarkup');$f->attr('name', '_examplePageOrFalse2');$f->label = $alternateLabel;$f->showIf = 'derefAsPage=' . FieldtypePage::derefAsPageOrFalse;$f->icon = 'scissors';$f->value ="<pre class='language-php'><code>" . $this->wire('sanitizer')->entities("if(\$page->{$field->name}) {" ."\n echo \$page->{$field->name}(\"<a href='{url}'>{title}</a>\");" ."\n}") . "</code></pre>";$exampleFieldset->add($f);/** @var InputfieldMarkup $f */$f = $this->wire('modules')->get('InputfieldMarkup');$f->attr('name', '_examplePageOrNullPage');$f->label = $_labels['PageOrNullPage'];$f->showIf = 'derefAsPage=' . FieldtypePage::derefAsPageOrNullPage;$f->icon = 'scissors';$f->value ="<pre class='language-php'><code>" . $this->wire('sanitizer')->entities("if(\$page->{$field->name}->id) {" ."\n echo \"<a href='{\$page->{$field->name}->url}'>{\$page->{$field->name}->title}</a>\";" ."\n}") . "</code></pre>";$exampleFieldset->add($f);/** @var InputfieldMarkup $f */$f = $this->wire('modules')->get('InputfieldMarkup');$f->attr('name', '_examplePageOrNullPage2');$f->label = $alternateLabel;$f->showIf = 'derefAsPage=' . FieldtypePage::derefAsPageOrNullPage;$f->icon = 'scissors';$f->value ="<pre class='language-php'><code>" . $this->wire('sanitizer')->entities("if(\$page->{$field->name}->id) {" ."\n echo \$page->{$field->name}(\"<a href='{url}'>{title}</a>\");" ."\n}") . "</code></pre>";$exampleFieldset->add($f);/** @var InputfieldCheckbox $checkbox */$value = (int) $field->get('allowUnpub');$checkbox = $this->modules->get('InputfieldCheckbox');$checkbox->attr('name', 'allowUnpub');$checkbox->label = $this->_('Allow unpublished pages?');$checkbox->description = $this->_('When checked, unpublished pages are allowed in the field value. Unpublished pages will not appear on the front-end, except to those with edit access.'); // Description for allowUnpub option$checkbox->attr('value', 1);$checkbox->attr('checked', $value ? 'checked' : '');$checkbox->icon = 'eye-slash';if(!$value) $checkbox->collapsed = Inputfield::collapsedYes;$inputfields->append($checkbox);return $inputfields;}/*** Export configuration values for external consumption** Use this method to externalize any config values when necessary.* For example, internal IDs should be converted to GUIDs where possible.** FieldtypePage: Note the exported config values here are actually from* InputfieldPage, but we're handling them in here rather than* InputfieldPage::exportConfigData() to increase the reusability of these* conversions (parent_id and template_id) which are common conversions* used by other Fieldtypes.** @param Field $field* @param array $data* @return array**/public function ___exportConfigData(Field $field, array $data) {$data = parent::___exportConfigData($field, $data);if(!empty($data['parent_id']) && ctype_digit("$data[parent_id]")) {// convert parent ID to parent path$data['parent_id'] = $this->wire('pages')->get((int) $data['parent_id'])->path;}foreach(array('template_id', 'template_ids') as $key) {if(empty($data[$key])) continue;if(is_array($data[$key])) {// convert array of template ids to template namesforeach($data[$key] as $k => $id) {if(ctype_digit("$id")) continue;$template = $this->wire('templates')->get((int) $id);if($template) $data[$key][$k] = $template->name;}} else if(ctype_digit((string) $data[$key])) {// convert template id to template name$template = $this->wire('templates')->get((int) $data[$key]);if($template) $data[$key] = $template->name;}}return $data;}/*** Convert an array of exported data to a format that will be understood internally** FieldtypePage: Note the mported config values here are actually from* InputfieldPage, but we're handling them in here rather than* InputfieldPage::importConfigData() to increase the reusability of these* conversions (parent_id and template_id) which are common conversions* used by other Fieldtypes.*** @param Field $field* @param array $data* @return array Data as given and modified as needed. Also included is $data[errors], an associative array* indexed by property name containing errors that occurred during import of config data.**/public function ___importConfigData(Field $field, array $data) {$data = parent::___importConfigData($field, $data);// parent pageif(!empty($data['parent_id']) && !ctype_digit("$data[parent_id]")) {// we have a page apth rather than id$id = $this->wire('pages')->get("path=" . $this->wire('sanitizer')->selectorValue($data['parent_id']))->id;if(!$id) $data['errors']['parent_id'] = $this->_('Unable to find page') . " - $data[parent_id].";$data['parent_id'] = $id;}// templateforeach(array('template_id', 'template_ids') as $property) {if(empty($data[$property])) continue;// template_id can be an id or array of IDs, but we will be importing a template name or array of them$errors = array();$isArray = is_array($data[$property]);if(!$isArray) $data[$property] = array($data[$property]);foreach($data[$property] as $key => $name) {if(ctype_digit("$name")) continue;// we have a template name rather than id$template = $this->wire('templates')->get($this->wire('sanitizer')->name($name));if($template) {$data[$property][$key] = $template->id;} else {$errors[] = $this->_('Unable to find template') . " - $name.";}}if(!$isArray) $data[$property] = reset($data[$property]);if(count($errors)) $data['errors'][$property] = implode(" \n", $errors);}return $data;}/*** Find and clean orphaned references in each of FieldtypePage's tables** Previous versions of PW had an issue where a reference to a deleted page could still exist in some instances.* This could cause "reference.count>0" type selectors to produce inaccurate results. This cleans up for that.* This may also be handy if a Page reference table has become corrupted by some other means.***/public function cleanOrphanedReferences() {$database = $this->wire('database');$totalCleaned = 0;foreach($this->wire('fields') as $field) {if(!$field->type instanceof FieldtypePage) continue;$table = $database->escapeTable($field->getTable());foreach(array('data', 'pages_id') as $key) {$sql = "SELECT $table.pages_id, $table.data FROM $table LEFT JOIN pages ON $table.$key=pages.id WHERE pages.id IS NULL";$query = $database->prepare($sql);$query->execute();$numCleaned = 0;/** @noinspection PhpAssignmentInConditionInspection */while($row = $query->fetch(\PDO::FETCH_NUM)) {list($pages_id, $data) = $row;$q = $database->prepare("DELETE FROM $table WHERE pages_id=:pages_id AND data=:data");$q->bindValue(':pages_id', $pages_id, \PDO::PARAM_INT);$q->bindValue(':data', $data, \PDO::PARAM_INT);$q->execute();$numCleaned++;}$totalCleaned += $numCleaned;if($numCleaned) $this->message("Fixed $numCleaned orphaned '$key' references for field '$field->name'");}}if(!$totalCleaned) $this->message("No problems found");else $this->message("Found and fixed a total of $totalCleaned issues.");}/*** Return pages referencing the given $page, optionally indexed by field name** The default behavior when no arguments are provided (except $page) is to simply return a PageArray of all pages* referencing the given one (excluding hidden, unpublished, trash, no-access, etc.). Specify "include=all"* as your $selector if you want to include all pages without filtering. If the quantity may be large,* you should also include a "limit=100" (replacing 100 with your limit). This method supports pagination* when specifying a limit.** @param Page $page Page to get references for* @param string|bool $selector Optionally filter/modify returned result, i.e. "limit=10, include=all", etc.* Or boolean TRUE as shortcut for "include=all".* @param bool|Field|null $field Optionally specify Field to limit results to (default includes all fields of this type),* Or boolean TRUE to return array indexed by field name.* @param bool $getCount Specify true to get a count (int) rather than a PageArray (default=false)* @return PageArray|array|int Returns one of the following, according to the provided arguments:* - returns PageArray as default behavior, including when given a $selector string and/or Field object.* - returns array of PageArray objects if $field argument is TRUE ($selector may be populated string or blank string).* - returns int if the count option (boolean true) specified for $selector.**/public function findReferences(Page $page, $selector = '', $field = false, $getCount = false) {/** @var Pages $pages */$pages = $this->wire('pages');// modifier option defaults$byField = false;$includeAll = $selector === true || $selector === "include=all";$findLimit = 200;$fieldName = '';// determine whether to use include=allif($selector === true) {$selector = "include=all";} else if(strlen($selector) && !$includeAll && strpos($selector, "include=all") !== false) {foreach(new Selectors($selector) as $s) {if($s->field() === 'include' && $s->value() === 'all') {$includeAll = true;break;}}}if(is_bool($field) || is_null($field)) {$byField = $field ? true : false;} else if(is_string($field)) {$fieldName = $this->wire('sanitizer')->fieldName($field);} else if(is_int($field)) {$field = $this->wire('fields')->get($field);if($field) $fieldName = $field->name;} else if($field instanceof Field) {$fieldName = $field->name;}// results$fieldNames = array(); // field names that point to $page, array of [ field_id => field_name ]$fieldCounts = array(); // counts indexed by field name (if count mode)$total = 0;// first determine which fields have references to $pageforeach($this->wire('fields') as $field) {if($fieldName && $field->name != $fieldName) continue;if(!$field->type instanceof FieldtypePage) continue;$table = $field->getTable();$sql = "SELECT COUNT(*) FROM `$table` WHERE data=:id";$query = $this->wire('database')->prepare($sql);$query->bindValue(':id', $page->id, \PDO::PARAM_INT);$query->execute();$cnt = (int) $query->fetchColumn();if($cnt > 0) {$fieldNames[$field->id] = $field->name;$fieldCounts[$field->name] = $cnt;$total += $cnt;}$query->closeCursor();}// if they just asked for the count, then we have all that we need to finish nowif($getCount && $includeAll) {// return count or array of countsreturn $byField ? $fieldCounts : $total;}// if there was nothing found, finish earlyif(!$total) {// no references foundif($getCount) return $total;return $byField ? array() : $pages->newPageArray();}// perform another find() to filter results, and return requested result typeif($byField) {// return array of PageArrays indexed by fieldName$result = array();foreach($fieldNames as $fieldName) {$s = rtrim("$fieldName=$page->id, $selector", ', ');if($getCount) {$cnt = $pages->count($s);if($cnt) $result[$fieldName] = $cnt;} else {if($total > $findLimit) {$items = $pages->findMany($s);} else {$items = $pages->find($s);}if($items->count()) $result[$fieldName] = $items;}}} else {// return PageArray of all references$selector = rtrim(implode('|', $fieldNames) . "=$page->id, $selector", ', ');if($getCount) {$result = $pages->count($selector);} else if($total > $findLimit) {$result = $pages->findMany($selector);} else {$result = $pages->find($selector);}}return $result;}/*** Return array or string of configured template IDs** Accounts for both template_id and template_ids settings, making sure both are included.** #pw-internal** @param Field|array $field Field object or array with all possible template IDs* @param bool $getString Specify true to return a 1|2|3 style string rather than an array* @return array|string**/static public function getTemplateIDs($field, $getString = false) {$ids = array();$values = array();if($field instanceof Field) {$values = array($field->get('template_id'), $field->get('template_ids'));} else if(is_array($field)) {$values = $field;}foreach($values as $value) {if(empty($value)) continue;if(!is_array($value)) $value = array($value);foreach($value as $id) {$id = (int) $id;if($id) $ids[$id] = $id;}}return $getString ? implode('|', $ids) : array_values($ids);}/*** Module configuration screen** @param array $data* @return InputfieldWrapper**/public function getModuleConfigInputfields(array $data) {if($data) {}$inputfields = $this->wire(new InputfieldWrapper());/** @var InputfieldCheckbox $inputfield */$inputfield = $this->wire('modules')->get('InputfieldCheckbox');$inputfield->attr('name', '_clean');$inputfield->attr('value', 1);$inputfield->label = $this->_('Find and clean orphaned page references');$inputfield->description = $this->_('This cleans up for an issue in older versions of ProcessWire that could leave orphaned page references for deleted pages. If you are getting inaccurate results from page finding operations (especially with selectors using pageref.count), then you may want to run this.'); // Find and clean description$inputfield->icon = 'eraser';$inputfield->collapsed = Inputfield::collapsedYes;$inputfield->notes = $this->_('Warning: To be safe you should back-up your database before running this.'); // Find and clean notes$inputfields->add($inputfield);if($this->wire('input')->post('_clean')) {$this->message($this->_('Finding and cleaning...'));$this->wire('fieldtypes')->get('FieldtypePage')->cleanOrphanedReferences();}return $inputfields;}}