<?php namespace ProcessWire;

/**
 * Process Lister
 *     __    _      __           
 *    / /   (_)____/ /____  _____
 *   / /   / / ___/ __/ _ \/ ___/
 *  / /___/ (__  ) /_/  __/ /    
 * /_____/_/____/\__/\___/_/     
 *
 * Provides an alternative listing view for pages using specific templates. 
 *
 * ProcessWire 3.x, Copyright 2022 by Ryan Cramer
 * https://processwire.com
 *
 * For support of actions, new edit modules, and custom configurable Listers, 
 * check out ListerPro at http://processwire.com/ListerPro/
 * 
 * 
 * GET vars recognized by Lister:
 * 
 * session_bookmark: name of session bookmark (session_bookmark=...)
 * pageNum: page number, if specified as GET var rather than URL segment (pageNum=1)
 * open: CSV string of page IDs that should be open/selected (open=1,2,3)
 * reset: Initiates reset of filters (reset=1)
 * minimal: Minimal mode shows only results and nothing else (minimal=1, usually combined with modal=1)
 * 
 *
 * PROPERTIES
 * 
 * @property string $initSelector The selector string that may not be changed.
 * @property string $defaultSelector The default selector string that appears but MAY be removed or changed [template=, title%=]
 * @property array $statusLabels Array of status labels of status_name => label
 * @property null|Page $parent Parent page for all listed children (optional)
 * @property null|Template $template Template page for all listed children (optional)
 * @property array $columns Array of columns (field names) to display in the lister 
 * @property array $delimiters Delimiters for multi-value column values, indexed by field name
 * @property string $defaultSort Default field to sort by [-modified]
 * @property int $defaultLimit Max items to show per pagination [25]
 * @property int $imageWidth Width for thumbnails, 0=proportional to height [0]
 * @property int $imageHeight Height for thumbnails, 0=proportional to width [100]
 * @property int|bool $imageFirst Show only first image? [false] 
 * @property int $imageStyle 1=Detailed, 0=Image only (default=0)
 * @property int $viewMode View mode, see windowMode constants [self::windowModeNone]
 * @property int $editMode Edit mode, see windowMode constants [self::windowModeNone] 
 * @property string $editURL Edit page base URL [$config->urls->admin . 'page/edit/'] 
 * @property string $addURL Add page base URL [$config->urls->admin . 'page/add/'] 
 * @property array $disallowColumns columns that may not be displayed [array(pass,config)]
 * @property bool $useColumnLabels whether to use labels for fields (versus names) in column selection and column labels [true] 
 * @property bool $allowSystem whether or not system templates/fields are allowed for selection. for system fields, it only refers to system custom fields excluding title. [false]
 * @property bool $allowIncludeAll allow include=all or check_access=0 mode when user is non-superuser? [false]
 * @property bool $allowBookmarks Whether or not to allow bookmarks
 * @property bool $preview whether or not to show the selector string preview in InputfieldSelector [self::debug]
 * @property bool $cacheTotal cache the total, per selector, for increased performance? [true] 
 * @property array $toggles One or more of: collapseFilters, collapseColumns, noNewFilters, disableColumns, noButtons [empty]
 * @property array $systemLabels Array of system page labels of field_name => label
 * @property array $limitFields Limit selectable filters/columns to only those present in this array [empty]
 * @property string $nativeDateFormat wireDate() format for created and modified dates
 * @property bool $showIncludeWarnings Whether or not to show warnings about what pages were excluded to to max "include=" mode [true]
 * @property string $blankLabel Label when a field is blank (default=Blank)
 * @property int $editOption Edit option setting, used only by ListerPro
 * @property bool $responsiveTable Whether or not Lister table should be responsive rather than horiz scroll
 * @property bool $configMode Configuration mode where some limits are removed (default=false)
 * 
 * @method string buildListerTableColActions(Page $p, $value)
 * @method string renderResults($selector = null)
 * @method string getSelector($limit = null)
 * @method PageArray findResults($selector)
 * @method string executeReset()
 * @method string executeEditBookmark()
 * @method string executeViewport() ListerPro
 * @method string executeConfig() ListerPro
 * @method string executeActions() ListerPro
 * @method string executeSave() ListerPro
 * @method string renderedExtras($markup) #pw-hooker
 * 
 * 
 * @todo make system fields hookable for output like markupValue is for custom fields
 * @todo show bookmark title somewhere on page so users know what they are looking at (+ browser title?)
 *
 */

class ProcessPageLister extends Process implements ConfigurableModule {

	/**
	 * Makes additional info appear for Lister development
	 *
	 */
	const debug = false;

	/**
	 * Name of session variable used for Lister bookmarks
	 *
	 */
	const sessionBookmarksName = '_lister_bookmarks';

	/**
	 * Constants for the $window config option
	 *
	 */
	const windowModeNone = 0; // regular link
	const windowModeModal = 2; // opens modal
	const windowModeBlank = 4; // opens target=_blank
	const windowModeHide = 8; // doesn't show
	const windowModeDirect = 16; // click takes you directly there

	/**
	 * Instance of InputfieldSelector
	 *
	 */
	protected $inputfieldSelector = null;

	/**
	 * Cached totals per selector, so we don't have to re-calculate on each pagination
	 *
	 */
	protected $knownTotal = array(
		'selector' => '', 
		'total' => null, // null=unset, integer=set
		'time' => 0, 
	);

	/**
	 * Default columns to display when none configured
	 *
	 */
	protected $defaultColumns = array('title', 'template', 'parent', 'modified', 'modified_users_id'); 

	/**
	 * Default selector to use when none defined
	 *
	 */
	protected $defaultInitSelector = '';

	/**
	 * Final selector string sent to Pages::find, for debugging purposes
	 *
	 */
	protected $finalSelector = '';

	/**
	 * Final selector string after being fully parsed by PageFinder (used only in debug mode)
	 * 
	 * @var string
	 * 
	 */
	protected $finalSelectorParsed = '';

	/**
	 * IDs of pages that should appear open automatically
	 * 
	 * May be set by GET variable 'open' or by openPageIDs setting. 
	 *
	 */
	protected $openPageIDs = array();

	/**
	 * Additional notes about the results to display underneath them
	 * 
	 * @var array
	 * 
	 */
	protected $resultNotes = array();

	/**
	 * Render results during this request?
	 * 
	 * @var bool
	 * 
	 */
	protected $renderResults = false;

	/**
	 * Initalize module config variables
	 *
	 */
	public function __construct() {
		parent::__construct();
		if(!$this->wire()->page) return;

		$this->defaultColumns = array('title', 'template', 'parent', 'modified', 'modified_users_id'); 
	
		// default init selector
		$initSelector = "has_parent!=2"; // exclude admin
		
		/*
		if($this->wire('user')->isSuperuser()) {
			$initSelector .= "include=all";
		} else if($this->wire('user')->hasPermission('page-edit')) {
			$initSelector .= "include=unpublished";
		} else {
			$initSelector .= "include=hidden";
		}
		*/

		// exclude admin pages from defaultInitSelector when user is not superuser
		// if(!$this->wire('user')->isSuperuser()) $initSelector .= ", has_parent!=" . $this->wire('config')->adminRootPageID; 

		// the initial selector string that all further selections are filtered by
		// this selector string is not visible to user are MAY NOT be removed or changed, 
		// except for 'sort' properties. 
		$this->set('initSelector', $initSelector); 

		// the default selector string that appears but MAY be removed or changed
		$this->set('defaultSelector', "template=, title%="); 

		// Array of status labels of status_name => label
		$this->set('statusLabels', array()); 

		// Parent page for all listed children (optional)
		$this->set('parent', null); 

		// Template page for all listed children (optional)
		$this->set('template', null);

		// Array of columns (field names) to display in the lister
		$this->set('columns', $this->defaultColumns);

		// Delimiters for multi-value column values, indexed by field name
		$this->set('delimiters', array());

		// Default field to sort by
		$this->set('defaultSort', '-modified');

		// Max items to show per pagination
		$this->set('defaultLimit', 25); 

		// image width/height for thumbnail images
		$this->set('imageWidth', 0);
		$this->set('imageHeight', 100);
		$this->set('imageFirst', 0);
		$this->set('imageStyle', 0); // 0=image only, 1=detailed

		// view and edit window modes
		$this->set('viewMode', self::windowModeNone); 
		$this->set('editMode', self::windowModeNone); 
		$this->set('editURL', $this->wire('config')->urls->admin . 'page/edit/'); 
		$this->set('addURL', $this->wire('config')->urls->admin . 'page/add/'); 

		// columns that may not be displayed
		$this->set('disallowColumns', array('pass', 'config')); 
	
		// limit selectable columns and filters to only those present in this array
		// if empty, then it is not applicable
		$this->set('limitFields', array()); 
	
		// whether to use labels for fields (versus names) in column selection and column labels
		$this->set('useColumnLabels', true); 

		// whether or not system templates/fields are allowed for selection
		// for system fields, it only refers to system custom fields excluding title
		$this->set('allowSystem', false); 
	
		// allow include=all or check_access=0 mode when user is non-superuser?
		$this->set('allowIncludeAll', false); 
	
		// allow bookmarks?
		$this->set('allowBookmarks', true);
	
		// whether or not to show the selector string preview in InputfieldSelector
		$this->set('preview', self::debug); 
	
		// cache the total, per selector, for increased performance?
		$this->set('cacheTotal', true); 
	
		// toggles: collapseFilters, collapseColumns, noNewFilters, disableColumns, noButtons
		$this->set('toggles', array()); 
	
		// date format for native properties: created and modified
		$this->set('nativeDateFormat', $this->_('rel')); // Native date format: use wireDate(), PHP date() or strftime() date format
	
		// whether or not to show warnings about max include mode
		$this->set('showIncludeWarnings', true); 
	
		// label when a field is blank
		$this->set('blankLabel', $this->_('Blank')); 
	
		// bookmarked selectors
		$this->set('bookmarks', array()); 
	
		// whether or not table collapses (rather than horiz scroll) at lower resolutions
		$this->set('responsiveTable', true);
	
		// configuration mode where some limits are removed
		$this->set('configMode', false);
	
		$idLabel = $this->_('ID');
		$pathLabel = $this->_('Path');
		$nameLabel = $this->_('Name'); 
		$createdLabel = $this->_('Created'); 
		$modifiedLabel = $this->_('Modified');
		$publishedLabel = $this->_('Published'); 
		$modifiedUserLabel = $this->_('Mod By'); 
		$createdUserLabel = $this->_('Created By');
		$templateLabel = $this->_('Template'); 
		$statusLabel = $this->_('Status'); 
		$parentLabel = $this->_('Parent'); 

		// Array of system page labels of field_name => label
		$this->set('systemLabels', array(
			'id' => $idLabel,
			'name' => $nameLabel, 
			'path' => $pathLabel, 
			'status' => $statusLabel, 
			'template' => $templateLabel,
			'templates_id' => $templateLabel . ' ' . $idLabel,
			'modified' => $modifiedLabel,
			'created' => $createdLabel,
			'published' => $publishedLabel, 
			'modified_users_id' => $modifiedUserLabel, 
			'created_users_id' => $createdUserLabel,
			'parent' => $parentLabel,
			'num_children' => $this->_('Num Children'),
			'url' => 'URL', 
			'httpUrl' => 'Http URL',
		));

		$this->statusLabels = array(
			Page::statusHidden => $this->_('Hidden'),
			Page::statusUnpublished => $this->_('Unpublished'), 
			Page::statusLocked => $this->_('Locked'), 
			Page::statusTrash => $this->_('Trash'), 
		);

		// remembering pagination
		$input = $this->wire()->input;
		$pageNum = $input->pageNum; 
		$pageNum2 = (int) $this->sessionGet('pageNum'); 
		if($input->get('pageNum')) { 
			// okay, keep pageNum
		} else if($pageNum > 1) {
			// okay, also just fine
		} else if($pageNum2 > 1) {
			$pageNum = $pageNum2; 
		}
		
		$this->sessionSet('pageNum', $pageNum); 
		$input->setPageNum($pageNum); 
	}

	/**
	 * Initalize lister variables
	 *
	 */
	public function init() {
		if(!$this->wire()->page) return;
		
		$input = $this->wire()->input;
		$config = $this->wire()->config;
		$config->admin = true;
		
		$this->checkSessionBookmark();
		
		$columns = $this->sessionGet('columns'); 
		if($columns) $this->columns = $columns; 
		$ajax = $config->ajax;
		
		$this->renderResults = $ajax && $input->post('render_results') > 0;
		
		if($this->renderResults) $this->processInput();

		if(!$this->template) {
			$selector = $this->initSelector;
			if($ajax) {
				$s = $this->getInputfieldSelector();
				$selector .= ", $s->value";
			}
			$template = $this->getSelectorTemplates($selector); 
			if(count($template) === 1) $this->set('template', reset($template));
		}

		if($input->post('reset_total')) {
			$this->sessionSet('knownTotal', null);
		} else {
			$knownTotal = $this->sessionGet('knownTotal');
			if(is_array($knownTotal)) $this->knownTotal = $knownTotal;
		}

		parent::init();
	}

	/**
	 * Check for a bookmark specified in GET variable $n
	 * 
	 * @param string $bookmarkID 
	 * @return null|string|false Returns NULL if not applicable, boolean false if bookmark not found, or integer of bookmark ID if applied
	 * 
	 */
	public function checkBookmark($bookmarkID = '') {
		if(!$bookmarkID) $bookmarkID = $this->wire()->input->get('bookmark');
		if(!$bookmarkID) return null;
		$bookmarks = $this->getBookmarksInstance();
		$bookmarkID = $bookmarks->_bookmarkID($bookmarkID);
		if(!$bookmarkID) return false;
		$bookmark = $bookmarks->getBookmark($bookmarkID); 
		if(!$bookmark || !$bookmarks->isBookmarkViewable($bookmark)) return false;
		$this->sessionClear();
		$this->set('defaultSelector', $bookmark['selector']);
		$this->set('defaultSort', $bookmark['sort']);
		$this->sessionSet('sort', $bookmark['sort']);
		$this->set('columns', $bookmark['columns']);
		$this->headline($this->wire()->page->title . ' - ' . $bookmark['title']);
		return $bookmarkID;
	}
	
	/**
	 * Check and process session bookmarks and regular bookmarks
	 *
	 * If a session_bookmark GET variable is provided with a number that corresponds to session variable
	 * PageListerBookmarks[session_bookmark] then this function will pull out and use any settings
	 * specified there rather than the defaults.
	 *
	 */
	protected function checkSessionBookmark() {
	
		// check for regular bookmarks first: those specified as $_GET['n']
		$bookmarkID = $this->checkBookmark();
		if(is_bool($bookmarkID) || is_int($bookmarkID)) return (bool) $bookmarkID;
	
		// then check for session bookmarks
		$id = $this->wire()->input->get('session_bookmark');
		$clear = $id; 
		if(!$id) $id = $this->sessionGet('bookmark'); 
		if(is_null($id)) return false;
		$id = $this->wire()->sanitizer->name($id); 

		$bookmarks = $this->wire()->session->get(self::sessionBookmarksName);

		if(!is_array($bookmarks) || !isset($bookmarks[$id]) || !is_array($bookmarks[$id])) {
			$this->error($this->_('Unrecognized bookmark or bookmark no longer active'));
			$this->sessionClear();
			return false;
		}

		if($clear) { 
			//$this->message("Using session bookmark: $id", Notice::debug); 
			$this->sessionClear();
			$this->sessionSet('bookmark', $id); 
		}

		foreach($bookmarks[$id] as $key => $value) {
			if(array_key_exists($key, $this->data)) {
				$this->set($key, $value);
			}
		}

		return true;
	}

	/**
	 * Given a unique ID and an array of Lister settings (in $bookmark) return a URL to view those pages in Lister
	 *
	 * @param string $id ID or name of bookmark
	 * @param array $bookmark Bookmark data
	 * @return string Returns URL to Lister with this bookmark or blank on failure (like if user doesn't have access)
	 *
	 */
	public static function addSessionBookmark($id, array $bookmark) {
	
		$user = wire()->user;
		if(!$user->isSuperuser() && !$user->hasPermission('page-lister')) return '';
		
		$maxBookmarks = 30;
		$bookmarks = wire()->session->get(self::sessionBookmarksName);
		if(!is_array($bookmarks)) $bookmarks = array();
	
		if(count($bookmarks) > $maxBookmarks) {
			// trim bookmarks to max size
			$bookmarks = array_slice($bookmarks, -1 * $maxBookmarks, null, true); 
		}
	
		$bookmarks[$id] = $bookmark;
		wire()->session->set(self::sessionBookmarksName, $bookmarks);
		
		return wire()->config->urls->admin . "page/lister/?session_bookmark=$id";
	}

	
	/**
	 * Get the InputfieldSelector instance for this Lister
	 *
	 * @return InputfieldSelector
	 *
	 */
	public function getInputfieldSelector() {
		if($this->inputfieldSelector) return $this->inputfieldSelector; 
		/** @var InputfieldSelector $s */
		$s = $this->wire()->modules->get('InputfieldSelector'); 
		$s->attr('name', 'filters'); 
		$s->attr('id', 'ProcessListerFilters'); 
		$s->initValue = $this->initSelector; 
		if($this->template) $s->initTemplate = $this->template;
		$s->label = $this->_('Filters'); 
		$s->addLabel = $this->_('Add Filter'); 
		$s->icon = 'search-plus';
		$s->preview = $this->preview; 
		$s->counter = false;
		$s->allowSystemCustomFields = $this->allowSystem; 
		$s->allowSystemTemplates = $this->allowSystem; 
		$s->allowSubfieldGroups = false; // we only support in ListerPro
		$s->allowSubselectors = false; // we only support in ListerPro
		$s->exclude = 'sort';
		$s->limitFields = $this->limitFields;
		$s->showFieldLabels = $this->useColumnLabels ? 1 : 0; 
		if(in_array('collapseFilters', $this->toggles)) $s->collapsed = Inputfield::collapsedYes; 
		if(in_array('disableFilters', $this->toggles)) {
			$s->attr('disabled', 'disabled');
		}
		$selector = (string) $this->sessionGet('selector');
		if($this->initSelector) {
			if(strpos($selector, $this->initSelector) !== false) {
				$selector = str_replace($this->initSelector, '', $selector); // ensure that $selector does not contain initSelector
			}
		}
			
		if(!strlen($selector)) {
			$selector = $this->defaultSelector;
		} else if($this->defaultSelector && strpos($selector, $this->defaultSelector) === false) {
			$selector = $this->combineSelector($this->defaultSelector, $selector); 
		}
		$s->attr('value', $selector); 
		$this->inputfieldSelector = $s; 
		return $s; 
	}

	/**
	 * Set a Lister setting 
	 *
	 * @param string $key
	 * @param mixed $value
	 * @return ProcessPageLister|Process
	 *
	 */
	public function set($key, $value) {
		if($key === 'openPageIDs' && is_array($value)) {
			$this->openPageIDs = $value; 
			return $this; 
		} else if($key === 'parent' && !$value instanceof Page) {
			$value = $this->wire()->pages->get($value); 
		} else if($key === 'finalSelector') {
			$this->finalSelector = $value;
		} else if($key === 'limitFields' && !is_array($value)) {
			$value = $this->wire()->sanitizer->array($value);
		}
		return parent::set($key, $value);
	}

	/**
	 * Get a Lister setting
	 * 
	 * @param string $key
	 * @return mixed|string
	 * 
	 */
	public function get($key) {
		if($key === 'finalSelector') return $this->finalSelector;
		return parent::get($key);
	}


	/**
	 * Set a Lister session variable
	 *
	 * @param string $key
	 * @param string|int|array $value
	 *
	 */
	public function sessionSet($key, $value) {
		$key = $this->page->name . '_lister_' . $key;
		if(is_null($value)) {
			$this->wire()->session->remove($key);
		} else {
			$this->wire()->session->set($key, $value);
		}
	}

	/**
	 * Get a Lister session variable
	 *
	 * @param string $key
	 * @param array|string|int $fallback Optional fallback value if session value not present
	 * @return string|int|array|null
	 *
	 */
	public function sessionGet($key, $fallback = null) {
		$key = $this->page->name . '_lister_' . $key;
		$value = $this->wire()->session->get($key); 
		if($value === null && $fallback !== null) $value = $fallback;
		return $value;
	}

	/**
	 * Clear all Lister session variables
	 *
	 */
	public function sessionClear() {
		$name = $this->page->name; 
		$session = $this->wire()->session;
		foreach($session as $key => $value) {
			if(strpos($key, "{$name}_lister_") === 0) $session->remove($key); 
		}
	}
	

	/**
	 * Process input for the filters form and populate session variables with the results
	 *
	 */
	protected function processInput() {
		
		$input = $this->wire()->input;
		$sanitizer = $this->wire()->sanitizer;

		$filters = $input->post('filters');
		if($filters !== null && $filters !== 'ignore') {
			$is = $this->getInputfieldSelector();
			try {
				$is->processInput($input->post);
				$selector = $this->sessionGet("selector");
				$isSelector = (string) $is->value; // selector from InputfieldSelector
				if($selector === $this->defaultSelector && !strlen($isSelector)) {
					// do not reset if selector matches default selector
				} else if($selector != $isSelector) {
					// reset
					$this->sessionSet("selector", $isSelector);
					$this->sessionSet("pageNum", 1);
					$input->setPageNum(1);
				}
			} catch(\Exception $e) {
				$this->error($e->getMessage());
			}
		} 

		$value = $input->post('columns'); 
		if($value !== null && $value !== 'ignore' && !in_array('disableColumns', $this->toggles)) { 
			$columns = array();
			$columnOptions = $this->sessionGet('columnOptions');
			if(empty($columnOptions)) {
				$columnOptions = $this->buildColumnsField()->getOptions();
				$this->sessionSet('columnOptions', $columnOptions);
			}
			foreach($sanitizer->array($value) as $name) {
				$name = $sanitizer->name($name); 	
				if(strlen($name) && isset($columnOptions[$name])) $columns[] = $name;
			}
			if(count($columns)) {
				$this->sessionSet('columns', $columns); 
				$this->columns = $columns; 
			}
		}

		$sort = $input->post('sort');
		if($sort !== null) {
			$sort = $sanitizer->name($sort); 
			if(strlen($sort)) {
				$this->sessionSet("sort", $sort);
				$this->set('sort', $sort);
			}
		}
	}

	/**
	 * Build the columns asmSelect
	 *
	 */
	public function buildColumnsField() {

		$fields = $this->wire()->fields;
		$systemColumns = $this->getSystemColumns();
		$useLabels = $this->useColumnLabels;
		$systemLabels = $this->getSystemLabels();
		$template = $this->template;
		$customFields = array();
		$languages = $this->wire()->languages;
		$languagePageNames = $languages && $languages->hasPageNames() && method_exists($this, '___executeSave'); 
		$asmParents = array('name', 'path', 'url', 'httpUrl');
		$asmParentValueSuffix = ' …';
		
		/** @var InputfieldAsmSelect $f */
		$f = $this->wire()->modules->get('InputfieldAsmSelect'); 
		$f->attr('name', 'columns'); 
		$f->label = $this->_('Default columns'); 
		$f->description = $this->_('Select and sort the columns that will display in the pages list table.');
		$f->notes = $this->_('The user can optionally change which columns are shown, so these will just serve as the defaults.'); // columns description
		$f->icon = 'table';
	
		// system fields
		foreach($systemColumns as $field) {
			
			$label = isset($systemLabels[$field]) ? $systemLabels[$field] : $field;
			$isAsmParent = $languagePageNames && in_array($field, $asmParents);
			
			if($useLabels) {
				$label1 = $label;
				$label2 = $field;
			} else {
				$label1 = $field;
				$label2 = $label;
			}
			
			$attrs = array();
			
			if($isAsmParent) {
				$asmParentValue = $field . $asmParentValueSuffix;
				$value = $asmParentValue;
				$attrs['class'] = 'asmParent';
				$label1 .= " $asmParentValueSuffix";
				$label2 .= " $asmParentValueSuffix";
				
			} else {
				$value = $field;
			}
			
			$attrs['data-desc'] = $label2;
			
			$f->addOption($value, $label1, $attrs);
			
			if($languagePageNames && $isAsmParent) {
				$f->addOption("$field", ($useLabels ? $label : $value), array(
					'data-desc' => ($useLabels ? $value : $label),
					'data-asmParent' => $value,
					'class' => 'asmChild',
				));
				foreach($languages as $language) {
					$langLabel = $this->addLanguageLabel($label, $language, true); 
					$langValue = "$field-$language->name";
					$f->addOption($langValue, ($useLabels ? $langLabel : $langValue), array(
						'data-desc' => ($useLabels ? $langValue : $langLabel),
						'data-asmParent' => $value,
						'class' => 'asmChild',
					));
				}
			}
		}
		
		$f->addOption('-', '———', array('disabled' => 'disabled')); 
	
		// custom fields (sort)
		foreach($fields as $field) {
			/** @var Field $field */
			if(!$this->allowColumnField($field)) continue;
			if($useLabels) {
				if($template) {
					$_field = $template->fieldgroup->getField($field->name, true); // context
					if($_field) $field = $_field;
				}
				$key = $field->getLabel(); 
				if(isset($customFields[$key])) $key .= " $field->name";
			} else {
				$key = $field->name;
			}
			$customFields[$key] = $field; 
		}
		
		ksort($customFields);
	
		// custom fields (add)
		foreach($customFields as $field) {
			
			if($template) {
				$_field = $template->fieldgroup->getField($field->name, true); // context
				if($_field) $field = $_field;
			}
			
			if($useLabels) {
				$label = $field->getLabel();
				$desc = $field->name;
			} else {
				$label = $field->name;
				$desc = $field->getLabel();
			}
			
			$attr = array('data-desc' => $desc);
			$icon = $field->getIcon(true);
			if($icon) $attr['data-handle'] = wireIconMarkup($icon, 'fw');
			
			$f->addOption($field->name, $label, $attr);
		}
		
		$f->attr('value', $this->columns); 
		
		return $f; 
	}

	/**
	 * Get plain array of system field names, for use as columns in buildColumnsField
	 *
	 */
	protected function getSystemColumns() {
		$systemColumns = array_keys($this->systemLabels); 
		$systemColumns = array_merge($systemColumns, array('id', 'name', 'path', 'url', 'httpUrl')); 
		sort($systemColumns);
		return $systemColumns; 
	}

	/**
	 * Get array of system labels, indexed by property name
	 * 
	 * @return array
	 * 
	 */
	protected function getSystemLabels() {
		$labels = $this->systemLabels;
		if($this->template) {
			$label = $this->template->getNameLabel();	
			if($label) $labels['name'] = $label;
		}
		return $labels;
	}

	/**
	 * Whether or not to allow the given $field as a column, for buildColumnsField
	 * 
	 * @param Field $field
	 * @return bool
	 *
	 */
	protected function allowColumnField(Field $field) {
	
		if(in_array($field->name, $this->disallowColumns)) return false;
		if(count($this->limitFields) && !$this->configMode && !in_array($field->name, $this->limitFields)) {
			if(!in_array($field->name, $this->columns)) return false;
		}
		if($field->type instanceof FieldtypeFieldsetOpen) return false;
		
		static $templates = array();
		if(empty($templates)) {
			$templates = $this->getSelectorTemplates($this->initSelector); 
		}
		
		if(count($templates)) {
			$allow = false; 
			foreach($templates as $template) {
				if($template->fieldgroup->hasField($field)) {
					$_field = $template->fieldgroup->getFieldContext($field); 
					if($_field) $field = $_field;
					$allow = $field->viewable();
					break;
				}
			}
			
		} else {
			$allow = $field->viewable();
		}
		
		return $allow; 
	}

	/**
	 * Build the Lister columns form
	 *
	 * @return InputfieldForm
	 *
	 */
	protected function buildColumnsForm() {

		/** @var InputfieldForm $form */
		$form = $this->wire()->modules->get('InputfieldForm'); 
		$form->attr('id', 'ProcessListerColumnsForm');
		$form->method = 'get';
		$form->action = './';
		$form->class .= ' WireTab';
		$form->attr('title', $this->_x('Columns', 'tab'));

		$f = $this->buildColumnsField();
		$f->description .= ' ' . $this->_('The changes you make here should be reflected immediately in the results below.');
		$f->attr('id', 'lister_columns'); 
		$f->label = $this->_('What columns to show in the results'); 
		$f->notes = '';
		$form->add($f); 

		return $form;
	}
	
	/**
	 * Build the Lister filters form
	 *
	 * @return InputfieldForm
	 *
	 */
	protected function buildFiltersForm() {

		/** @var InputfieldForm $form */
		$form = $this->wire()->modules->get('InputfieldForm'); 
		$form->attr('id', 'ProcessListerFiltersForm');
		$form->method = 'get';
		$form->action = './';
		$form->class .= ' WireTab';

		$f = $this->getInputfieldSelector();
		$f->class .= ' WireTab';	
		$form->attr('title', $f->label); 
		$f->label = $this->_('What pages to show');
		if(in_array('noNewFilters', $this->toggles)) $f->allowAddRemove = false;
		$form->add($f);

		/** @var InputfieldHidden $f */
		$f = $this->wire()->modules->get('InputfieldHidden'); 
		$f->attr('name', 'sort'); 
		$f->attr('id', 'lister_sort'); 
		$f->attr('value', $this->sessionGet('sort')); 
		$form->add($f); 

		return $form; 
	}

	/**
	 * Given two selector strings, combine them into one without duplicates
	 * 
	 * @param $s1
	 * @param $s2
	 * @return string
	 * 
	 */
	protected function combineSelector($s1, $s2) {
		
		if(empty($s2)) return $s1; 
		if(empty($s1)) return $s2; 
		
		try {
			$selectors1 = $this->wire(new Selectors($s1));
		} catch(\Exception $e) {
			$this->error($e->getMessage());
			$selectors1 = new Selectors();
		}
		try {
			$selectors2 = $this->wire(new Selectors($s2));
		} catch(\Exception $e) {
			$this->error($e->getMessage());
			$selectors2 = new Selectors();
		}
		
		foreach($selectors1 as /* $key1 => */ $selector1) {
			//$value = $selector1->value; 
			//if(is_array($value) || strlen($value)) continue;
			$fieldName1 = $selector1->field; 
			if(is_array($fieldName1)) $fieldName1 = implode('|', $fieldName1);
			// see if we have the same field in selectors2
			foreach($selectors2 as /* $key2 => */ $selector2) {
				$fieldName2 = $selector2->field; 
				if(is_array($fieldName2)) $fieldName2 = implode('|', $fieldName2); 
				if($fieldName1 == $fieldName2) {
					// move value from selector2 to selector1
					$selectors1->replace($selector1, $selector2); 
					$selectors2->remove($selector2);
					// break out now so that additional values don't get replaced again
					break;  
				}
			}
		}
		
		$combined = ((string) $selectors1) . ", " . ((string) $selectors2);
		return $combined; 	
		
	}


	/**
	 * Get the selector string to be used in finding results
	 *
	 * @param int $limit Max number of results per pagination
	 * @return string
	 *
	 */
	public function ___getSelector($limit = null) {

		$selector = $this->sessionGet('selector');
		if(!$selector) $selector = $this->initSelector; 
		if($this->initSelector && strpos($selector, $this->initSelector) === false) {
			$selector = "$this->initSelector, $selector";
		}
		
		// optionally limit results to just those present in CSV row_page_id POST var containing page IDs, like: 1,2,3
		if(isset($_POST['row_page_id'])) {
			$pageIDs = array();
			foreach(explode(',', $this->wire()->input->post('row_page_id')) as $id) {
				$id = (int) $id;
				if($id) $pageIDs[] = $id;
			}
			if(count($pageIDs)) {
				$selector .= ", id=" . implode('|', $pageIDs);
				return $this->validateSelector($selector);
			}
		}

		if(stripos($selector, 'limit=') === false) {
			// no limit is specified in the selector
			if($limit) {
				$selector .= ", limit=" . (int) $limit; 
			} else if(is_null($limit)) {
				$selector .= ", limit=" . $this->defaultLimit; 
			}

		} else if(!is_null($limit)) { 
			// limit is specified in both the selector and the arguments. 
			// we don't allow specifying limit in selector if one is specified in the arguments
			$selector = preg_replace('/[, ]*\blimit=\d+/i', '', $selector); 
			if($limit > -1) $selector .= ", limit=" . (int) $limit; 
		}

		if(stripos($selector, 'sort=') !== false) {
			// we don't allow specifying the sort in the selector
			// since it is covered by the $this->sort property
			$selector = preg_replace('/[, ]*\bsort=([^,]|$)+/i', '', $selector); 
		}

		$sort = $this->sessionGet("sort");
		if(!$sort) $sort = $this->defaultSort; 
		if(!$sort || $sort == 'path') $sort = 'name';
		if($sort == '-path') $sort = '-name';
		$selector .= ", sort=$sort";

		$selector = trim($selector, ', '); 
		$selector = $this->validateSelector($selector);

		return $selector; 
	}

	/**
	 * Validate the given selector string for current user's access
	 *
	 * @param string $selector
	 * @return string
	 * @throws WireException
	 * @todo move to separate class so that this functionality can be used elsewhere
	 *
	 */
	protected function validateSelector($selector) {
	
		$user = $this->wire()->user;
		$sanitizer = $this->wire()->sanitizer;
		$pages = $this->wire()->pages;
		
		$showIncludeWarnings = $this->showIncludeWarnings; // whether to show warning message about removed include modes
		
		if($user->isSuperuser()) {
			if(!preg_match('/(^|,\s|,)include=/', $selector)) {
				$selector .= ", include=unpublished";
			}
			return $selector;
		}
		
		if(!preg_match('/(^|,\s|,)include=/', $selector)) {
			// if user has page-edit access, they can see unpublished pages by default
			if($user->hasPermission('page-edit')) {
				$selector .= ", include=unpublished";
			}
		}
	
		/** @var Selectors $selectors */
		$selectors = $this->wire(new Selectors($selector));
		$templates = array();
		$parents = array();
		$changed = false;
		$templateSelector = null;
		$includeSelector = null;
		
		foreach($selectors as $s) {
			
			$fields = is_array($s->field) ? $s->field : array($s->field);
			$values = is_array($s->value) ? $s->value : array($s->value);
			
			foreach($fields as $key => $name) {
				$fields[$key] = strtolower($name); 
			}
			
			$firstField = reset($fields);
			
			if(in_array('check_access', $fields) || in_array('checkaccess', $fields)) {
				if(!$this->allowIncludeAll) { 
					// don't allow non-superusers to specify a check_access property
					$selectors->remove($s);
					$this->error("check_access property not allowed here"); 
					$changed = true;
				}
			} 
			
			if(in_array('template', $fields) || in_array('template_id', $fields) || in_array('templates_id', $fields)) {
				
				foreach($values as $key => $value) {
					
					$value = $sanitizer->templateName($value); 
					if(ctype_digit("$value")) $value = (int) $value;
					$template = $this->wire()->templates->get($value); 	
					
					if(!$template) {
						unset($values[$key]); 
						$s->value = $values; 
						$changed = true; 
						if($value) $this->error("Unknown templates specified");

						/*
					} else if(!$user->hasPermission('page-view', $template)) {
						unset($values[$key]);
						$s->value = $values;
						$changed = true;
						$this->error("Template specified for which page-view access does not exist."); 
						*/
						
					} else {
						$templates[] = $template;
					}
				}
				$templateSelector = $s; 
				
			} 
			
			if(($firstField === 'parent' || $firstField === 'parent.id') && count($fields) == 1) {
				foreach($values as $value) {
					if(ctype_digit("$value")) {
						$parent = $pages->get((int) $value);
					} else {
						$parent = $pages->get($sanitizer->selectorValue($value));
					}
					if($parent->id) $parents[] = $parent;
				}
			}
			
			if(in_array('include', $fields)) {
				
				if(count($values) > 1) throw new WireException("The 'include=' selector may not have more than 1 value.");
				$includeSelector = $s; 
				$value = strtolower(trim(reset($values))); 
				if($value != 'unpublished' && $value != 'hidden') {
					// value must be 'all' or 'trash', which we don't allow
					if($value == 'all' && $this->allowIncludeAll) { 
						// ok, override
					} else { 
						$selectors->remove($s);
						if($value && $showIncludeWarnings) {
							$this->resultNotes[] = $this->_("Specified 'include=' mode is not allowed here.") . " (include=$value)";
						}
						$changed = true; 
					}
				}
				
			}
		}
		
		if($templateSelector) {
			// not currently used
		}
		
		if($includeSelector) {
			$includeMode = $includeSelector->value; 
			
			if(count($templates)) {
				// user specified 1 or more templates
				$numEditable = 0;
				$testPage = $pages->newPage();
				$testPage->id = 999; // required (any ID number works)
				if($this->wire()->permissions->has('page-publish')) {
					$testPage->addStatus(Page::statusUnpublished);
				}
				// determine how many templates are editable
				if(count($parents)) {
					foreach($templates as $template) {
						$testPage->template = $template;
						foreach($parents as $parent) {
							$testPage->parent = $parent;
							if($testPage->editable()) {
								$numEditable++;
								break;
							}
						}
					}
				} else {
					foreach($templates as $template) {
						$testPage->template = $template;
						if($testPage->editable()) $numEditable++;
					}
				}
				// if all specified templates are editable, include=unpublished is allowed
				if($numEditable == count($templates)) {
					// include=unpublished is allowed
				} else if($includeMode == 'unpublished') {
					// include=unpublished is not allowed
					if($showIncludeWarnings) {
						$this->resultNotes[] = $this->_("Not all specified templates are editable. Only 'include=hidden' is allowed");
					}
					$includeSelector->value = 'hidden';
					$changed = true; 
				}
				
			} else {
				// with no template specified
				// only allow a max include mode of hidden
				// regardless of edit access
				if($includeMode != 'hidden') {
					if($showIncludeWarnings) {
						$this->resultNotes[] = $this->_("No templates specified so 'include=hidden' is max allowed include mode");
					}
					$includeSelector->value = 'hidden';
					$changed = true; 
				}
			}
		}
		
		if($changed) {
			// rebuild the selector string and return it
			return (string) $selectors;
		} 
	
		// return unmodified
		return $selector;
	}

	/**
	 * Determine allowed templates from selector string
	 * 
	 * If a template is specified in the selector, keep track of it so that we may use it
	 * for determining what fields to show and 'add new' page features. 
	 *
	 * @param string $selector
	 * @param bool $getArray
	 * @return array
	 *
	 */
	public function getSelectorTemplates($selector, $getArray = true) {
		$return = $getArray ? array() : '';
		$templates = array();
		if(stripos("$selector", 'template=') === false) return $return;
		if(!preg_match('/(?:^|[^.])\btemplate=([^,]+)/i', $selector, $matches)) return $return;
		if(!$getArray) return $matches[1]; // return pipe separated string
		$template = explode('|', $matches[1]); 
		$templatesAPI = $this->wire()->templates;
		foreach($template as $t) {
			/** @var Template $t */
			$t = $templatesAPI->get($t); 
			if($t instanceof Template) $templates[] = $t; 
		}
		return $templates; 
	}

	/**
	 * Append a language label to given $label and return it
	 * 
	 * @param string $label
	 * @param Language$language
	 * @param bool $showDefault
	 * @return string
	 * 
	 */
	protected function addLanguageLabel($label, $language, $showDefault = false) {
		if(!$language) return $label;
		if($showDefault || !$language->isDefault()) {
			$label .= ' (' . $language->get('name') . ')';
		}
		return $label;
	}

	/**
	 * Is field or field+subfield sortable in the table?
	 * 
	 * @param Field $field
	 * @param string $subfield
	 * @return bool
	 * @since 3.0.194
	 * 
	 */
	protected function isSortableCol(Field $field, $subfield = '') {
		if(!$this->isSortableField($field)) return false;
		if($subfield === 'data' || empty($subfield) || $subfield === 'count') return true;
		if($subfield === 'keys' || $subfield === 'xtra') return false;
		$fieldtype = $field->type;
		$schema = $fieldtype->getDatabaseSchema($field);
		if(isset($schema[$subfield])) return true;
		$selectorInfo = $fieldtype->getSelectorInfo($field);
		if(isset($selectorInfo['input']) && $selectorInfo['input'] === 'page') {
			if(isset($selectorInfo['subfields'][$subfield])) return true;
			if($this->wire()->pages->loader()->isNativeColumn($subfield)) return true;
		} else if(!empty($selectorInfo['subfields'][$subfield]['col'])) {
			// selector info indicates specific DB col that is different from 'name'
			return true;
		}
		return false;
	}

	/**
	 * Is field sortable? 
	 * 
	 * @param Field $field
	 * @return bool
	 * @since 3.0.199
	 * 
	 */
	protected function isSortableField(Field $field) {
		$table = $field->getTable();
		if(empty($table)) return false;
		if(!$this->wire()->database->tableExists($field->getTable())) return false;
		return true;
	}

	/**
	 * Build the Lister table containing results
	 *
	 * @param PageArray $results
	 * @return MarkupAdminDataTable
	 *
	 */
	protected function buildListerTable(PageArray $results) {
		
		$sanitizer = $this->wire()->sanitizer;
		$modules = $this->wire()->modules;
		$fields = $this->wire()->fields;
		$adminTheme = $this->wire()->adminTheme;

		/** @var Languages $languages */
		$columns = $this->sessionGet('columns');
		$systemLabels = $this->getSystemLabels();
		$tableFields = array();
		$header = array();
		$useLabels = $this->useColumnLabels;
		
		/** @var MarkupAdminDataTable $table */
		$table = $modules->get('MarkupAdminDataTable');
		$table->setSortable(false);
		$table->setResizable(wireInstanceOf($adminTheme, 'AdminThemeUikit')); // non-100% width error w/default+reno themes
		$table->setResponsive($this->responsiveTable);
		$table->setClass('ProcessListerTable');
		$table->setEncodeEntities(false);
		
		if(!$columns) $columns = $this->columns; 

		foreach($columns as $key => $name) {
			
			// determine if field is specifying different langauge
			$language = $this->identifyLanguage($name, true);
			$subname = '';
			
			if(strpos($name, '.')) list($name, $subname) = explode('.', $name);
			$field = $this->template ? $this->template->fieldgroup->getField($name, true) : $fields->get($name);
			if(!$field && $this->template) $field = $fields->get($name);
			
			$label = $field ? $field->getLabel() : '';
			if(!$label) $label = isset($systemLabels[$name]) ? $systemLabels[$name] : $name;
			$icon = $field ? $field->getIcon(true) : '';
			
			$sep1 = '~~';
			$sep2 = '&nbsp;&gt; ';
			$label = str_replace($sep1, ' ', $label);
		
			if($subname) {
				$sublabel = '';
				if($field && $useLabels) {
					$subinfo = $field->type->getSelectorInfo($field);
					if(!empty($subinfo['subfields'][$subname]['label'])) {
						$sublabel = $subinfo['subfields'][$subname]['label'];
					}
				}
				if(!$sublabel) {
					$subfield = $fields->get($subname);
					if($subfield) {
						$sublabel = $subfield->getLabel();
						$sublabel = str_replace($sep1, ' ', $sublabel);
						if(!$sublabel) $sublabel = $subname;
						$subicon = $subfield->getIcon(true);
						if($subicon) $icon = $subicon;
					}
					if(!$sublabel) $sublabel = $subname;
				}
				$label .= $sep1 . $sublabel;
			}
			
			if($language) {
				$label = $this->addLanguageLabel($label, $language);
			}
			
			$label = $sanitizer->entities1($label);
			$label = str_replace($sep1, "$sep2<wbr>", $label);
			
			if($icon) {
				// the following code ensures the first word of the label and icon don't get split on separate lines
				$icon = "<strong>" . wireIconMarkup($icon, 'fw');
				if(strpos($label, ' ')) {
					$words = explode(' ', $label);
					$label = array_shift($words) . ' </strong>';
					$label .= implode(' ', $words);
				} else {
					$label .= '</strong>';
				}
			} else if($subname) {
				$label = "<strong>" . str_replace($sep2, "$sep2</strong>", $label); 
			}
		
			$thClass = '';
			if($subname) {
				$sortKey = "<b>$name.$subname</b>";
				if($field && !$this->isSortableCol($field, $subname)) {
					$thClass = 'not_sortable';
				}
			} else if($field && !$this->isSortableField($field)) {
				$sortKey = "<b>$name</b>";
				$thClass = 'not_sortable';
			} else {
				$sortKey = "<b>$name</b>";
				if($name === 'path' || $name === 'url' || $name === 'httpUrl') $thClass = 'not_sortable';
			}
		
			$th = "$icon$label$sortKey";
			if($thClass) $th = array($th, $thClass);
			$header[$key] = $th;
			$tableFields[$name] = $field; 
		}

		$table->headerRow($header); 

		foreach($results as $p) {
			$table->row($this->buildListerTableRow($p, $tableFields, $columns), array(
				'attrs' => array('data-pid' => $p->id)
			)); 
		}

		return $table;
	}

	/**
	 * Build the Lister table row from a Page
	 *
	 * @param Page $p
	 * @param array $fields
	 * @param array $columns
	 * @return array
	 *
	 */
	protected function buildListerTableRow(Page $p, array $fields, array $columns) { 

		$p->of(false);
		$values = array();

		foreach($columns as /* $cnt => */ $name) {
			$value = $this->buildListerTableCol($p, $fields, $name); 
			$values[] = $value; 
		}

		return $values; 
	}

	/**
	 * Build the Lister table column from a Page and column name
	 *
	 * @param Page $p
	 * @param array $fields
	 * @param string $name
	 * @param mixed $value Not used at present 
	 * @return string
	 *
	 */
	protected function buildListerTableCol(Page $p, array $fields, $name, $value = null) {
		if($value) {} // ignore, used by ListerPro only

		$sanitizer = $this->wire()->sanitizer;
		$languages = $this->wire()->languages;
		$hooks = $this->wire()->hooks;
		
		if($languages && $p->template->noLang) $languages = null;
		$langPageNames = $languages && $languages->hasPageNames();

		$subname = '';
		$fullname = $name; 
		$value = null;
		$language = $languages ? $this->identifyLanguage($name, true) : null;
	
		if($language && $language->id == $this->wire()->user->language->id) $language = null;
		if($language) $languages->setLanguage($language);
		
		if(strpos($name, '.')) list($name, $subname) = explode('.', $name, 2); 
		if($name == 'config' || $subname == 'config') return 'Not allowed';
		
		reset($fields); 
		$isFirstCol = key($fields) == $name; 
		
		/** @var Field $field */
		$field = isset($fields[$name]) ? $fields[$name] : $this->wire('fields')->get($name);
		$delimiter = isset($this->delimiters[$fullname]) ? $this->delimiters[$fullname] : "<br />";

		// if parent and subname present, make the parent the page and subname the field
		if($subname && $name === 'parent') {
			$p = $p->parent();
			list($name, $subname) = array($subname, ''); 
			$field = isset($fields[$name]) ? $fields[$name] : $this->wire('fields')->get($name);
		} 
		
		if($languages && $field && $language) {
			$value = $this->getLanguageValue($p, $field, $language);
		} else if($langPageNames && $language && in_array($name, array('name', 'path', 'url', 'httpUrl'))) {
			$value = $this->getLanguageValue($p, $name, $language);
		}
	
		if($value === null) $value = $p->getFormatted($name);

		if(!$subname && ($value instanceof Page || $value instanceof PageArray)) {
			if($field && $field->get('labelFieldName')) {
				$subname = $field->get('labelFieldName');
				if($sanitizer->fieldName($subname) == $subname) $subname .= "|name"; // fallback
			} else {
				$subname = 'title|name';
			}
		}

		if($subname == 'count' && $value instanceof WireArray) {
			// count
			$value = $value->count();

		} else if($value instanceof Page) {
			// page
			if($field && $field->type) {
				$value = $field->type->markupValue($p, $field, $value, $subname);
			} else {
				$value = $sanitizer->entities($value->getUnformatted($subname));
			}

		} else if($value instanceof PageArray) {
			// pages
			if($delimiter == '<br />') {
				// default delimiter: let markupValue handle it (default output)
				$value = $field->type->markupValue($p, $field, $value, $subname);
			} else {
				// custom delimiter specified, so we'll custom render it here
				$newValue = array();
				foreach($value as $v) {
					$v = $field->type->markupValue($p, $field, $v, $subname);
					$newValue[] = $v;
				}
				$value = implode($delimiter, $newValue);
			}
			
		} else if($value instanceof Pagefiles && (!$subname || $subname == 'data')) {
		
			// Pagefiles or Pageimages
			if($value->count()) {
				if($value instanceof Pageimages && $this->imageFirst) $value = $value->slice(0, 1);
				if($value instanceof Pageimages) {
					if($this->imageWidth || $this->imageHeight) {
						$field->inputfieldSetting('adminThumbs', true);
						$field->inputfieldSetting('adminThumbWidth', $this->imageWidth);
						$field->inputfieldSetting('adminThumbHeight', $this->imageHeight);
					}
					if($this->imageStyle == 0) {
						$field->inputfieldSetting('renderValueFlags', Inputfield::renderValueMinimal); // | Inputfield::renderValueNoWrap); 
					}
				}
				$field->inputfieldSetting('skipLabel', Inputfield::skipLabelHeader);
				$value = (string) $field->type->markupValue($p, $field, $value);
			} else {
				$value = '';
			}
			
		} else if($field && $field->type && $hooks->isMethodHooked($field->type->className(), 'markupValue')) {
			// if the markupValue method is hooked, let it have control
			if($subname == 'data') $subname = '';
			$value = $field->type->markupValue($p, $field, $value, $subname);
			// value may be MarkupFieldtype object
			$value = (string) $value;

		} else if($value instanceof WireArray) {
			// WireArray type unknown to Lister
			$value = $field->type->markupValue($p, $field, $value, $subname);

		} else if(is_array($value)) {
			// unknown iterable value
			//if($value instanceof Pageimages && $this->imageFirst) $value = array($value->first());
			$values = array();
			$isImage = false;
			foreach($value as /* $k => */ $v) {
				if(empty($v)) continue; 
				if($subname == 'data') $v = (string) $v; 
				if($subname && is_object($v)) $v = $v->$subname; 
				if($v instanceof Pageimage) {
					$vfull = $v;
					if($this->imageWidth || $this->imageHeight) $v = $v->size($this->imageWidth, $this->imageHeight);
					$alt = $vfull->basename . ($vfull->description ? ' - ' . $sanitizer->entities1($vfull->description) : "");
					$v = 
						"<a href='$vfull->URL' title='$alt' class='lister-lightbox'>" . 
						"<img alt='$alt' src='$v->URL' style='margin: 4px 4px 4px 0' /></a>";
					$isImage = true;
					
				} else if($v instanceof Pagefile) {
					/** @var Pageimage $v */
					$v = "<a target='_blank' href='$v->url'>$v->basename</a>";
				} else {
					$v = $sanitizer->entities($v); 
				}
				$values[] = (string) $v;
				if($isImage && $this->imageFirst) break;
			}
			if($isImage && !isset($this->delimiters[$fullname])) $delimiter = ' ';
			$value = implode($delimiter, $values); 

		} else if(in_array($name, array('created', 'modified', 'published'))) {
			// @todo make this format customizable via wireDate()
			// date modified or created
			$value = "<span class='datetime'>" . wireDate($this->nativeDateFormat, $value) . "</span>";

		} else if($field && $field->type instanceof FieldtypeDatetime) {
			// datetime field
			// $value = $field->type->formatValue($p, $field, $value); 
			$value = "<span class='datetime'>$value</span>";

		} else if($field && $field->type instanceof FieldtypeCheckbox) {
			// checkbox field
			$value = $value ? wireIconMarkup('check-square-o') : wireIconMarkup('square-o');

		} else if(in_array($name, array('modified_users_id', 'created_users_id'))) {
			// user field
			$u = $name === 'modified_users_id' ? $p->modifiedUser : $p->createdUser;
			$value = $u && $u->id ? $u->name : "user_id:" . (int) $value; 

		} else if($name == 'status') {
			// status
			$value = array();
			foreach($this->statusLabels as $status => $label) {
				if($p->hasStatus($status)) $value[] = $label;
			}
			$value = implode(', ', $value);
			
		} else if($name === 'template') {
			// template label or name
			$allowName = $this->useColumnLabels ? trim((string) $this->get('sort'), '-') === 'template' : true;
			$t = $p->template;
			$value = $t->getLabel();
			// include template name only if it differs from template label
			if($allowName && strtolower($t->name) != strtolower($value)) $value .= " ($t->name)";
			$value = $sanitizer->entities1($value);

		} else if($field && $field->type) {
			if($subname == 'data') $subname = '';
			$value = $field->type->markupValue($p, $field, $value, $subname);
			// value may be MarkupFieldtype object
			$value = (string) $value;
			
		} else {
			// other or unknown
			if($subname == 'data') $value = (string) $value;
			if($subname && is_object($value)) $value = $value->$subname;
			$value = $sanitizer->entities($value); 
		}
	
		if($isFirstCol) $value = $this->buildListerTableColActions($p, $value);
		if($language) $languages->unsetLanguage();

		return $value; 

	}

	/**
	 * Identify language for given field name / column name
	 * 
	 * Language present as either 'field-de' (de is language name) or 'field.data1234' (1234 is language ID).
	 * Until LP requires version 3.0.137 or newer of Lister, any changes to this method should also be applied to LP.
	 * 
	 * @param string $name
	 * @param bool $remove Remove language identify from given field name?
	 * @return Language|null
	 * @since 3.0.137
	 * 
	 */
	public function identifyLanguage(&$name, $remove = false) {
		
		$languages = $this->wire()->languages;
		if(!$languages) return null;
		
		if(strpos($name, '-') && preg_match('/-([-_a-z0-9]+)$/', $name, $matches)) {
			// i.e. title-de or categories.title-de
			$language = $languages->get($matches[1]); 
		} else if(strpos($name, '.data') && preg_match('/\.data(\d+)$/', $name, $matches)) {
			// i.e. title.data1234 or categories.title.data1234
			$language = $languages->get((int) $matches[1]); 
		} else {
			// no language identified in field name
			$language = $this->wire()->user->language;
		}
		
		//if(!wireInstanceOf($field->type, 'FieldtypeLanguageInterface')) return null;
		if(!$language || !$language->id) $language = $languages->getDefault();
		if(!$language || !$language->id) $language = null;
		
		if($remove && $language) {
			$name = str_replace(array("-$language->name", ".data$language->id"), '', $name);
		}
		
		return $language;
	}

	/**
	 * Get value for language 
	 * @param Page $page
	 * @param string|Field $fieldName
	 * @param Language|string $language
	 * @return mixed
	 * 
	 */
	protected function getLanguageValue(Page $page, $fieldName, $language) {
		if($fieldName instanceof Field) $fieldName = $fieldName->name;
		$languages = $this->wire()->languages;
		if(!$languages) return $page->getFormatted($fieldName);
		if($fieldName === 'name') {
			$value = $page->localName($language);
		} else if($fieldName === 'path') {
			$value = $page->localPath($language);
		} else if($fieldName === 'url') {
			$value = $page->localUrl($language);
		} else if(strtolower($fieldName) === 'httpurl') {
			$value = $page->localHttpUrl($language);
		} else {
			$languages->setLanguage($language);
			$value = $page->getFormatted($fieldName);
			$languages->unsetLanguage();
		}
		return $value;
	}
	
	/**
	 * Build the Lister table column clickable actions
	 * 
	 * This essentially wraps the given $value with additional markup to open action links. 
	 *
	 * @param Page $p
	 * @param string $value
	 * @return string
	 *
	 */
	protected function ___buildListerTableColActions(Page $p, $value) {
		$class = '';
		$statusIcon = '';
		$isTrash = false; 
		
		if(!strlen($value)) {
			// column is blank
			$name = $p->name;
			$maxNameLen = 20;
			if(strlen($name) > $maxNameLen) {
				$parts = explode('-', $name); 
				while(strlen($name) > $maxNameLen) {
					array_pop($parts); 
					$name = implode('-', $parts);
				}
				if(!$name) $name = substr($p->name, 0, $maxNameLen);
				$name .= '&hellip;';
			}
			$value = "$this->blankLabel <small class='ui-priority-secondary'>($name)</small>";
		}
		
		if($p->hasStatus(Page::statusHidden)) $class .= " PageListStatusHidden";
		if($p->hasStatus(Page::statusUnpublished)) $class .= " PageListStatusUnpublished";
		if($p->hasStatus(Page::statusLocked)) {
			$class .= " PageListStatusLocked";
			$statusIcon .= wireIconMarkup('lock', 'fw ui-priority-secondary');
		}
		if($p->hasStatus(Page::statusTrash)) {
			$isTrash = true; 	
			$class .= " PageListStatusTrash PageListStatusUnpublished";
			$statusIcon .= wireIconMarkup('trash-o', 'fw ui-priority-secondary'); 
		}
		if($p->getAccessParent() === $p && $p->parent->id) {
			$accessTemplate = $p->getAccessTemplate();
			if($accessTemplate && $accessTemplate->hasRole('guest')) {
				$accessTemplate = $p->parent->getAccessTemplate();
				if($accessTemplate && !$accessTemplate->hasRole('guest') && !$p->isTrash()) {
					$class .= ' PageListAccessOn';
					$statusIcon .= wireIconMarkup('key', 'fw fa-flip-horizontal ui-priority-secondary');
				}
			} else {
				$accessTemplate = $p->parent->getAccessTemplate();
				if($accessTemplate && $accessTemplate->hasRole('guest')) {
					$class .= ' PageListAccessOff';
					$statusIcon .= wireIconMarkup('key', 'fw ui-priority-secondary');
				}
			}
		}
		$icon = $p->getIcon();
		$icon = $icon ? wireIconMarkup($icon, 'fw') : '';	
		if($class) $value = "<span class='" . trim($class) . "'>$value</span>";

		$actions = $this->getPageActions($p);
		unset($actions['move']); // not applicable in Lister
		
		$viewMode = $this->viewMode;
		$editMode = $this->editMode; 
		$editable = $editMode != self::windowModeHide ? isset($actions['edit']) : false;
		$viewable = $viewMode != self::windowModeHide ? isset($actions['view']) : false; 
		$addable = $editMode != self::windowModeHide ? isset($actions['new']) : false; 

		if($editable || $viewable || $addable) {
			
			$directURL = '';
			$actionsOut = '';
			
			if($editable) {
				$class = $editMode == self::windowModeModal ? "modal" : "";
				$target = $editMode == self::windowModeBlank ? "_blank" : "";
				if($editMode == self::windowModeDirect) $directURL = $actions['edit']['url'];
				$actionsOut .= $this->renderListerTableColAction($actions['edit'], $class, $target);
			}
			unset($actions['edit']);

			if($viewable) { 
				$class = $viewMode == self::windowModeModal ? "modal" : "";
				$target = $viewMode == self::windowModeBlank ? "_blank" : "";
				if($viewMode == self::windowModeDirect) $directURL = $p->url;
				$actionsOut .= $this->renderListerTableColAction($actions['view'], $class, $target);
			}
			unset($actions['view']);

			if($addable) {
				$actions['new']['url'] = $this->addURL . "?parent_id=$p->id";
				$class = $editMode == self::windowModeModal ? "modal" : "";
				$target = $editMode == self::windowModeBlank ? "_blank" : "";
				$actionsOut .= $this->renderListerTableColAction($actions['new'], "$class PageAdd PageEdit", $target);
			}
			unset($actions['new']);

			if($directURL) {
				// click goes directly to edit or view
				$value = "<a id='page$p->id' href='$directURL'>$icon$value$statusIcon</a>";
				
			} else {
				// click opens actions
				foreach($actions as $name => $action) {
					if($name == 'extras' && empty($action['extras'])) continue;
					$actionsOut .= $this->renderListerTableColAction($action); 
				}
				// extra actions
				if(isset($actions['extras'])) {
					foreach($actions['extras']['extras'] as $name => $action) {
						$class = empty($action['ajax']) ? '' : 'ajax';
						$target = '';
						if($name == 'copy' && $class != 'ajax') {
							// when using 'copy' action support modal mode like for edits
							if($editMode == self::windowModeModal) {
								$class .= " modal";
							} else {
								// make it redirect back to this Lister after a page clone
								$action['url'] .= "&redirect_page={$this->page}";
							}
							$target = $editMode == self::windowModeBlank ? "_blank" : "";
						}
						$actionsOut .= $this->renderListerTableColAction($action, "$class PageExtra Page$action[cn]", $target);
					}
				}

				if($actionsOut) $actionsOut = "<div class='PageListerActions actions'>$actionsOut</div>";
				$class = 'actions_toggle';
				if(in_array($p->id, $this->openPageIDs)) {
					$class .= ' open';
					unset($this->openPageIDs[$p->id]); 
					$this->sessionSet('openPageIDs', $this->openPageIDs); // ensure it is only used once
				}
				$value = "<a class='$class' id='page$p->id' href='#'>$icon$value$statusIcon</a> $actionsOut";
			}
		} else {
			$value = "<a class='actions_toggle' id='page$p->id' href='#'>$value$statusIcon</a>";
		}
		if($isTrash) $value = "<div class='ui-priority-secondary'>$value</div>";
		return $value; 
	}

	/**
	 * Render an action for a page 
	 * 
	 * @param array $action Action array from ProcessPageListActions
	 * @param string $class Class name for action
	 * @param string $target Target window (empty or _blank)
	 * @return string Rendered action
	 * 
	 */
	protected function renderListerTableColAction(array $action, $class = '', $target = '') {
		if(!$target && !empty($action['target'])) $target = $action['target'];
		if($target) $target = " target='$target'";
		if(!empty($action['cn'])) $action['cn'] = 'Page' . $action['cn'];
		$class = trim($action['cn'] . " $class"); 
		return "<a$target class='$class' href='$action[url]'>$action[name]</a> ";
	}

	/**
	 * Get an array of actions allowed for the given page
	 * 
	 * @param Page $p
	 * @return array()
	 * 
	 */
	protected function getPageActions(Page $p) {
		
		static $pageListRender = null;
		
		if(is_null($pageListRender)) {
			require_once($this->wire()->config->paths('ProcessPageList') . 'ProcessPageListRenderJSON.php');
			$pageListRender = new ProcessPageListRenderJSON($this->wire()->page, $this->wire()->pages->newPageArray());
			$this->wire($pageListRender);
			$pageListRender->setUseTrash($this->wire()->user->isSuperuser()); 
		}
	
		/** @var ProcessPageListRenderJSON $pageListRender */
		$actions = $pageListRender->getPageActions($p);

		if(isset($actions['edit']) && $this->editURL && strpos($actions['edit']['url'], '/page/edit/') !== false) {
			list($path, $queryString) = explode('?', $actions['edit']['url']);
			if($path) {} // ignore, descriptive only
			$actions['edit']['url'] = $this->editURL . '?' . $queryString;
		}
		
		return $actions;
	}

	/**
	 * Remove blank items like "template=, " from the selector string
	 *
	 * Blank items have a use for defaultSelector, but not for actually finding pages. 
	 *
	 * @param string $selector
	 * @return string
 	 *
	 */
	public function removeBlankSelectors($selector) {
		// $selector = preg_replace('/,\s*@?[_a-z0-9]+(=|!=|<=?|>=?|%=|\^=|\$=|\*=|~=)(?=,)/i', '', ",$selector");
		$opChars = str_replace('=', '', implode('', Selectors::getOperatorChars()));
		$regex = '/,\s*@?[_.a-z0-9]+(=|<|>|[' . $opChars . ']+=)(?=,)/i';
		$selector = preg_replace($regex, '', ",$selector,");
		return trim($selector, ', ');
	}


	/**
	 * Find the pages from the given selector string
	 *
	 * @param string $selector
	 * @return PageArray
 	 *
	 */
	protected function ___findResults($selector) {
		
		$pages = $this->wire()->pages;
		$config = $this->wire()->config;
		
		$selector = $this->removeBlankSelectors($selector); 	
		
		// remove start and/or limit
		$knownSelector = preg_replace('/\b(start=\d+|limit=\d+),?/', '', $selector); 
		
		$resetTotal = !$this->cacheTotal 
			|| ($this->knownTotal['total'] === null)
			|| ($this->knownTotal['selector'] != $knownSelector)
			|| ($pages->count("include=all, modified>=" . $this->knownTotal['time']) > 0);
		
		if($resetTotal) {
			$this->knownTotal['selector'] = $knownSelector; 
			$this->knownTotal['total'] = null;
			$this->knownTotal['time'] = 0; 
		}

		// if total is already known, don't bother having the engine count it
		if(!is_null($this->knownTotal['total'])) {
			$selector .= ", get_total=0";
		}
		
		$this->finalSelector = $selector; 

		try {
			$options = array('allowCustom' => true);
			$results = $selector ? $pages->find($selector, $options) : $pages->newPageArray();
			$this->finalSelector = $results->getSelectors(true);
			if($config->debug && $config->advanced) {
				$this->finalSelectorParsed = (string) $pages->loader()->getLastPageFinder()->getSelectors();
			}
			
		} catch(\Exception $e) {
			$this->error($e->getMessage());
			$results = $pages->newPageArray();
		}

		if(is_null($this->knownTotal['total'])) {
			$total = $results->getTotal();
			$this->knownTotal['total'] = $total;
			$this->knownTotal['time'] = time();
			$this->sessionSet('knownTotal', $this->knownTotal); 
		} else {
			$total = $this->knownTotal['total']; 
			$results->setTotal($total); 
		}

		return $results; 
	}


	/**
	 * Find and render the results (ajax)
	 *
	 * This is only called if the request comes from ajax
	 * 
	 * @param string|null $selector
	 * @return string
 	 *
	 */
	protected function ___renderResults($selector = null) {
		
		$sanitizer = $this->wire()->sanitizer;

		if(is_null($selector)) $selector = $this->getSelector();
		if(!count($this->columns)) $this->columns = $this->defaultColumns; 
		$results = $this->findResults($selector); 
		//$findSelector = $results->getSelectors();
		$out = '';
		$count = count($results);
		$start = $results->getStart();
		$limit = $results->getLimit();
		$total = $results->getTotal();
		$end = $start+$count; 
		$pagerOut = '';
		
		if(count($results)) {
			$table = $this->buildListerTable($results);
			$tableOut = $table->render();
			$headline = sprintf($this->_('%1$d to %2$d of %3$d'), $start+1, $end, $total); 
			if($limit > 0 && $total > $limit) {
				/** @var MarkupPagerNav $pager */
				$pager = $this->wire()->modules->get('MarkupPagerNav'); 
				$pagerOut = $pager->render($results);
				$pageURL = $this->wire()->page->url; 
				$pagerOut = str_replace($pageURL . "'", $pageURL . "?pageNum=1'", $pagerOut); // specifically identify page1
			}
			
		} else {
			$headline = $this->_('No results.');
			$tableOut = "<div class='ui-helper-clearfix'></div>";
		}
		
		foreach($this->wire()->notices as $notice) {
			/** @var Notice $notice */
			$noticeText = $notice->text; 
			if(!($notice->flags & Notice::allowMarkup)) {
				$noticeText = $sanitizer->entities1($notice->text);
			}
			if($notice instanceof NoticeError) {
				$out .= "<p class='ui-state-error-text'>$noticeText</p>";
			} else {
				// report non-error notifications only when debug constant active
				if(self::debug) $out .= "<p class='ui-state-highlight'>$noticeText</p>";
			}
		}

		$out .= "<h2 class='lister_headline'>$headline " . 
				"<span id='lister_open_cnt' class='ui-priority-secondary'>" . 
				"<i class='fa fa-check-square-o'></i> <span>0</span> " . $this->_('selected') . "</span></h2>" . 
				$pagerOut . 
				"<div id='ProcessListerTable'>$tableOut</div>" . 
				$pagerOut; 

		if(count($this->resultNotes)) {
			$notes = array();
			foreach($this->resultNotes as $note) {
				$notes[] = wireIconMarkup('warning') . ' ' . $sanitizer->entities1($note);
			}
			$out .= "<p id='ProcessListerResultNotes' class='detail'>" . implode('<br />', $notes) . "</p>";
		}
		if($this->wire()->config->debug) {
			$out .= "<p id='ProcessListerSelector' class='notes'>"; 
			if($this->finalSelectorParsed && $this->finalSelector != $this->finalSelectorParsed) {
				// selector was modified after being parsed by PageFinder
				$out .= 
					"<strong>1:</strong> " . $sanitizer->entities($this->finalSelector) . "<br />" . 
					"<strong>2:</strong> " . $sanitizer->entities($this->finalSelectorParsed);
			} else {
				$out .= $sanitizer->entities($this->finalSelector); 
			}
			$out .= "</p>";
		}
		
		if(!$this->editOption) {
			$out =
				"<form action='./' method='post' class='Inputfield InputfieldWrapper InputfieldForm InputfieldFormNoDependencies'>" .
					$out . $this->wire()->session->CSRF->renderInput() .
				"</form>";
		}

		
		$out .= $this->renderExternalAssets();
		
		return $out; 
	}

	/**
	 * Execute the reset action, which resets columns, filters, and anything else stored in the session
	 * 
	 */
	public function ___executeReset() {
		$this->resetLister();
		$this->message($this->_('All settings have been reset.'));
		if(strpos($this->wire()->input->urlSegmentStr, '/reset/')) {
			$this->wire()->session->location('../');
		} else {
			$this->wire()->session->location('./');
		}
	}

	/**
	 * Reset all Lister settings so it is starting from a blank state
	 * 
	 */
	public function resetLister() {
		$this->sessionClear();
		$this->wire()->session->remove(self::sessionBookmarksName);
		$this->sessionSet('knownTotal', null); 
	}

	/**
	 * Setup openPageIDs variables to keep track of which pages should automatically open
	 * 
	 * These are used by the buildListerTableColumnActions method. 
	 *
	 */
	protected function setupOpenPageIDs() {
		$input = $this->wire()->input;
		if(count($this->openPageIDs)) {
			$ids = $this->openPageIDs;
		} else if($input->get('open')) {
			$ids = explode(',', $input->get('open'));
		} else {
			$ids = $this->sessionGet('openPageIDs');
		}
		$openPageIDs = array();
		if(is_array($ids)) {
			foreach($ids as $id) {
				$id = (int) $id;
				$openPageIDs[$id] = $id;
			}
		}
		$this->openPageIDs = $openPageIDs;
		$this->sessionSet('openPageIDs', $openPageIDs);
	}

	/**
	 * Execute the main Lister action, which is to render the Lister
	 * 
	 * @return string
 	 *
	 */
	public function ___execute() {
		if(!$this->wire()->page) return '';
		
		$modules = $this->wire()->modules;
		$input = $this->wire()->input;
		
		$minimal = (int) $input->get('minimal'); 
	
		$this->setupOpenPageIDs();
	
		if($this->renderResults) {
			$out = $this->renderResults();
			if(self::debug) {
				foreach($this->wire()->database->queryLog() as $n => $item) {
					$out .= "<p>$n. $item</p>";
				}
			}
			return $out;
		}
		
		if($input->get('reset')) {
			return $this->executeReset();
		}

		$selector = $this->sessionGet('selector'); 
		if(!$selector) $this->sessionSet('selector', $this->defaultSelector); 

		$modules->get('JqueryWireTabs');
		$modules->get('MarkupAdminDataTable'); // to ensure css/js load
		
		$jQueryTableSorter = $modules->get('JqueryTableSorter'); /** @var JqueryTableSorter $jQueryTableSorter */
		$jQueryTableSorter->use('widgets');
		
		$modules->get('JqueryMagnific');
		
		if($input->get('modal') == 'inline') {
			$jQueryCore = $modules->get('JqueryCore'); /** @var JqueryCore $jQueryCore */
			$jQueryCore->use('iframe-resizer-frame');
		}

		$out = '';
		if($minimal) {
			/** @var InputfieldHidden $f */
			$sort = (string) $this->sessionGet('sort');
			$sort = htmlspecialchars($sort);
			$out .= "<input type='hidden' name='sort' id='lister_sort' value='$sort' />";
			$out .= "<input type='hidden' name='columns' id='lister_columns' value='ignore' />";
			$out .= "<input type='hidden' name='filters' id='ProcessListerFilters' value='ignore' />";
		} else {
			$out .= $this->buildFiltersForm()->render();
			if(!in_array('disableColumns', $this->toggles)) {
				$out .= $this->buildColumnsForm()->render();
			}
			if($this->allowBookmarks) {
				$bookmarks = $this->getBookmarksInstance();
				$out .= $bookmarks->buildBookmarkListForm()->render();
			}
			$out .= $this->renderExtras(); 
		}
		
		$out .= "<div id='ProcessListerResults' class='PageList $this->className'></div>";

		if(!in_array('noButtons', $this->toggles)) $out .= $this->renderButtons();
		$out .= $this->renderFooter();
		
		$this->prepareExternalAssets();
		
		return "<div id='ProcessLister'>$out</div>"; 
	}
	
	protected function renderFooter() {
		$out = '';
		if(!$this->wire()->input->get('minimal')) {
			$modules = $this->wire()->modules;
			$info = $modules->getModuleInfo($this);
			$out = "<p class='detail version'>$info[title] v" . $modules->formatVersion($info['version']) . "</p>";
		}
		return $out; 
	}
	
	protected function renderButtons() {
		$action = '';
		$out = '';
		
		if($this->parent && $this->parent->id && $this->parent->addable()) {
			$action = "?parent_id={$this->parent->id}";

		} else if($this->template && $this->template->noShortcut) {
			// not allowed in add-new list

		} else if($this->template && ($parent = $this->template->getParentPage(true))) {
			if($parent->id) {
				$action = "?parent_id=$parent->id"; // defined parent
			} else {
				$action = "?template_id={$this->template->id}"; // multiple possible parents
			}
		}

		if($action && !$this->wire()->input->get('minimal')) {
			/** @var InputfieldButton $btn */
			$btn = $this->wire()->modules->get('InputfieldButton');
			$btnClass = 'PageAddNew';
			if($this->editMode == self::windowModeModal) {
				$action .= "&modal=1";
				$btnClass .= " modal";
			}
			$btn->attr('value', $this->_('Add New'));
			$btn->href = $this->addURL . $action;
			$btn->showInHeader();
			$btn->icon = 'plus-circle';
			$btn->aclass = $btnClass;
			$out = $btn->render();
			if($this->editMode == self::windowModeBlank) $out = str_replace("<a " ,"<a target='_blank' ", $out);
		}
		
		return $out; 
	}

	/**
	 * Render additional tabs, setup so that descending classes can use as a template method
	 * 
	 * @return string
	 *
	 */
	public function renderExtras() {
		$refreshLabel = $this->_('Refresh results'); 
		$resetLabel = $this->_('Reset filters and columns to default'); 
		$out = "<div id='ProcessListerRefreshTab' title='$refreshLabel' class='WireTab WireTabTip'></div>";
		$out .= "<div id='ProcessListerResetTab' title='$resetLabel' class='WireTab WireTabTip'></div>";
		$out = $this->renderedExtras($out);
		return $out; 
	}

	/**
	 * Called when extra tabs markup has been rendered
	 * 
	 * Optionally hook this if you want to modify or add additional tabs markup returned by renderExtras()
	 * 
	 * #pw-hooker
	 *
	 * @param string $markup Existing tab markup already rendered
	 * @return string Contents of the $markup variable optionally prepended/appended with additional tab markup
	 * 
	 */
	public function ___renderedExtras($markup) {
		return $markup;
	}

	/**
	 * Prepare the session values for external assets
	 * 
	 * To be called during NON-ajax request only.
	 *
	 */
	public function prepareExternalAssets() {
		$config = $this->wire()->config;
		$loadedFiles = array();
		$loadedJSConfig = array();
		$regex = '!(Inputfield|Language|Fieldtype|Process|Markup|Jquery)!';
		foreach($config->scripts as $file) {
			if(!preg_match($regex, $file)) continue;
			$loadedFiles[] = $file;
		}
		foreach($config->styles as $file) {
			if(!preg_match($regex, $file)) continue;
			$loadedFiles[] = $file;
		}
		foreach($config->js() as $key => $value) {
			$loadedJSConfig[] = $key;
		}
		$this->sessionSet('loadedFiles', $loadedFiles);
		$this->sessionSet('loadedJSConfig', $loadedJSConfig);
	}

	/**
	 * Return a markup string with scripts that load external assets for an ajax request
	 * 
	 * To be used with ajax request only.
	 *
	 * @return string
	 *
	 */
	public function renderExternalAssets() {
		
		$config = $this->wire()->config;

		$script = '';
		$regex = '!(Inputfield|Language|Fieldtype|Process|Markup|Jquery)!';
		$scriptClose = '';
		$loadedFiles = $this->sessionGet('loadedFiles', array());
		$loadedFilesAdd = array();
		$loadedJSConfig = $this->sessionGet('loadedJSConfig', array());

		foreach($config->scripts as $file) {
			if(strpos($file, 'ProcessPageLister')) continue;
			if(!preg_match($regex, $file)) continue;
			if(in_array($file, $loadedFiles)) {
				// script was already loaded and can be skipped
				// if($this->wire('config')->debug) $script .= "\nconsole.log('skip: $file');";
			} else {
				// new script that needs loading
				//$script .= "\n<script src='$file'></script>";
				if($script) $script .= "\n";
				$script .= "$.getScript('$file', function(data, textStatus, jqxhr){";
				// "console.log(textStatus); ";
				$scriptClose .= "})";
				$loadedFilesAdd[] = $file;
			}
		}
		$script .= $scriptClose;

		foreach($config->styles as $file) {
			if(strpos($file, 'ProcessPageLister')) continue;
			if(!preg_match($regex, $file)) continue;
			if(!in_array($file, $loadedFiles)) {
				$script .= "\n$('<link rel=\"stylesheet\" type=\"text/css\" href=\"$file\">').appendTo('head');"; // console.log('$file');</script>";
				$loadedFilesAdd[] = $file;
			}
		}

		if(count($loadedFilesAdd)) {
			$loadedFiles = array_merge($loadedFiles, $loadedFilesAdd);
			$this->sessionSet('loadedFiles', $loadedFiles);
		}

		$jsConfig = array();
		foreach($config->js() as $property => $value) {
			if(!in_array($property, $loadedJSConfig)) {
				$loadedJSConfig[] = $property;
				$jsConfig[$property] = $value;
			}
		}

		if(count($jsConfig)) {
			$script .= "\n\n" . 'var configAdd=';
			$script .= json_encode($jsConfig) . ';';
			$script .= "\n\n" . '$.extend(config, configAdd);';
			//$script .= "console.log(configAdd);";
			$this->sessionSet('loadedJSConfig', $loadedJSConfig);
		}

		return "<div id='ProcessListerScript'>$script</div>";
	}

	/**
	 * Get an instance of ProcessPageListerBookmarks
	 * 
	 * @return ProcessPageListerBookmarks
	 * 
	 */
	public function getBookmarksInstance() {
		static $bookmarks = null;
		if(is_null($bookmarks)) {
			require_once(dirname(__FILE__) . '/ProcessPageListerBookmarks.php');
			$bookmarks = $this->wire(new ProcessPageListerBookmarks($this));
		}
		return $bookmarks;
	}

	/**
	 * Implementation for ./edit-bookmark/ URL segment
	 * 
	 * @return string
	 * @throws WirePermissionException|WireException
	 * 
	 */
	public function ___executeEditBookmark() {
		if(!$this->allowBookmarks) throw new WireException("Bookmarks are disabled");
		return $this->getBookmarksInstance()->executeEditBookmark();
	}

	/**
	 * Catch-all for bookmarks
	 * 
	 * @return string
	 * @throws Wire404Exception
	 * @throws WireException
	 * 
	 */
	public function ___executeUnknown() {
		$bookmarkID = (string) $this->wire()->input->urlSegment1;
		if(strpos($bookmarkID, 'bm') === 0) {
			$bookmarks = $this->getBookmarksInstance();
			$bookmarkID = $bookmarks->_bookmarkID(ltrim($bookmarkID, 'bm'));
		} else {
			$bookmarkID = '';
		}
		if(!$bookmarkID || !$this->checkBookmark($bookmarkID)) {
			throw new Wire404Exception('Unknown Lister action', Wire404Exception::codeNonexist);
		}
		return $this->execute();
	}

	/**
	 * Output JSON list of navigation items for this module's bookmarks
	 *
	 * @param array $options
	 * @return string|array
	 * 
	 */
	public function ___executeNavJSON(array $options = array()) {

		// make the add option a 'home' option, for root of tree

		$bookmarksInstance = $this->getBookmarksInstance();
		$bookmarks = $bookmarksInstance->getBookmarks();
	
		if(count($bookmarks) && $this->allowBookmarks) {
			
			$languages = $this->wire()->languages;
			$user = $this->wire()->user;

			$items = array();
			$options['add'] = "#tab_bookmarks";
			$options['addLabel'] = $this->_('Bookmarks');
			$options['addIcon'] = 'bookmark-o';
			$languageID = $languages && !$user->language->isDefault() ? $user->language->id : '';

			foreach($bookmarks as $bookmarkID => $bookmark) {
				$name = $bookmark['title'];
				$icon = $bookmark['type'] ? 'user-circle-o' : 'search';
				if($languageID && !empty($bookmark["title$languageID"])) $name = $bookmark["title$languageID"];
				$item = array(
					'id'   => $bookmarkID,
					'name' => $name,
					'icon' => $icon,
				);
				$items[] = $item;
			}

			$options['items'] = $items;
			$options['sort'] = false;
			$options['edit'] = "?bookmark={id}";
			$options['classKey'] = '_class';
		} else {
			// show nothing
			$options['add'] = null;
		}
		
		return parent::___executeNavJSON($options);
	}


	/**
	 * Install Lister
	 *
	 */
	public function ___install() {
	}

	/**
	 * Uninstall Lister
	 *
	 */
	public function ___uninstall() {

		/*
		$moduleID = $this->modules->getModuleID($this); 
		$pages = $this->pages->find("template=admin, process=$moduleID, include=all"); 
		foreach($pages as $page) {
			// if we found the page, let the user know and delete it
			if($page->process != $this) continue; // not really necessary
			$this->message("Deleting Page: {$page->path}"); 
			$page->delete();
		}
		*/

	}
	
	public static function getModuleConfigInputfields(array $data) {
		if($data) {} // ignore
		$inputfields = new InputfieldWrapper();
		return $inputfields;
	}

}

// we don't want lister bound to the default 999 pagination limit
wire()->config->maxPageNum = 99999; 
