<?php namespace ProcessWire;

/**
 * ProcessWire Image Select + Edit Process
 *
 * Provides the image selecting and editing capability for rich text editors (TinyMCE/CKEditor)
 * 
 * ProcessWire 3.x, Copyright 2023 by Ryan Cramer
 * https://processwire.com
 * 
 * @property int $hidpiDefault HiDPI/Retina checkbox default checked?
 * @property int $noThumbs Do not generate thumbnail images (make image selection will use full-size images).
 * @property string skipFields Space separated field names to skip for selection of images. 
 * @property int $noSizeAttrs Skip width attributes on image tags? (not recommended if you want HiDPI support)
 * @property string $alignLeftClass Align left class (align_left recommended)
 * @property string $alignRightClass Align right class (align_right recommended)
 * @property string $alignCenterClass Align center class (align_center recommended)
 * 
 * @method string execute()
 * @method string executeEdit()
 * 
 */

class ProcessPageEditImageSelect extends Process implements ConfigurableModule {
	
	public static function getModuleInfo() {
		return array(
			'title' => 'Page Edit Image',
			'summary' => 'Provides image manipulation functions for image fields and rich text editors.',
			'version' => 121,
			'permanent' => true,
			'permission' => 'page-edit',
		);
	}

	/**
	 * Max image width when outputting <img> tags, derives value from $_GET[winwidth]
	 * 
	 * @var int
	 * 
	 */
	protected $maxImageWidth = 835;

	/**
	 * Page that the image lives on
	 * 
	 * @var Page|null
	 * 
	 */
	protected $page = null;
	
	/**
	 * If $page is a repeater item, then $masterPage is the Page the repeater lives on 
	 * 
	 * @var Page|null
	 * 
	 */
	protected $masterPage = null;
	
	/**
	 * The page being edited, if different from $page
	 * 
	 * @var Page|null
	 *
	 */
	protected $editorPage = null;

	/**
	 * If editing a filename that is a variation, this is the width determined from the filename (123x456)
	 * 
	 * @var int
	 * 
	 */
	protected $editWidth = 0;
	
	/**
	 * If editing a filename that is a variation, this is the height determined from the filename (123x456)
	 *
	 * @var int
	 *
	 */
	protected $editHeight = 0;

	/**
	 * Whether or not HiDPI mode will be used or resizes
	 * 
	 * @var bool
	 * 
	 */
	protected $hidpi = false;

	/**
	 * Extensions to match in a regex for files in $_GET[file]
	 * 
	 * @var string
	 * 
	 */
	protected $extensions = 'jpg|jpeg|gif|png|svg';

	/**
	 * Common translation labels (see init)
	 * 
	 * @var array
	 * 
	 */
	protected $labels = array();

	/**
	 * Are we in Rich Text editor mode? Determined from $_GET[rte]
	 * 
	 * @var bool
	 * 
	 */
	protected $rte = true;

	/**
	 * Field of type FieldtypeImage that edited image is part of
	 * 
	 * @var Field|null
	 * 
	 */
	protected $field = null;

	/**
	 * Name of field of type FieldtypeImage that edited image is part of 
	 * 
	 * @var string
	 * 
	 */
	protected $fieldName = '';

	/**
	 * If file being edited is a variation, $original is basename of the file it originated from 
	 * 
	 * @var string
	 * 
	 */
	protected $original = '';

	/**
	 * Caption text for image when in RTE mode, can be provided in $_GET[caption]
	 * 
	 * @var string
	 * 
	 */
	protected $caption = '';

	/**
	 * Default module config settings
	 * 
	 * @var array
	 * 
	 */
	protected static $defaultConfig = array(
		'hidpiDefault' => 0, 
		'skipFields' => '',
		'noSizeAttrs' => 0,
		'noThumbs' => 0, 
		'alignLeftClass' => 'align_left',
		'alignRightClass' => 'align_right',
		'alignCenterClass' => 'align_center',
		);

	/**
	 * Construct and set default configuration
	 * 
	 */
	public function __construct() {
		parent::__construct();
		foreach(self::$defaultConfig as $key => $value) $this->set($key, $value);
		$this->labels = array(
			'width' => $this->_('Width:'),
			'height' => $this->_('Height:'),
			'top' => $this->_('Top:'),
			'left' => $this->_('Left:'),
			'hidpi' => $this->_('HiDPI/Retina'),
			'description' => $this->_('Image description (alt attribute)'),
			'linkOriginal' => $this->_('Link to larger/original version'),
			'resize' => $this->_('Resize'),
			'useResize' => $this->_('Save at this size?'), 
			'crop' => $this->_('Crop'),
			'saveCrop' => $this->_('Apply'),
			'max' => $this->_('Maximize/full width'),
			'min' => $this->_('Minimize/fit to screen'),
			'cancel' => $this->_('Cancel'),
			'alignLeft' => $this->_('Align Left'),
			'alignRight' => $this->_('Align Right'),
			'alignCenter' => $this->_('Align Center'),
			'saveReplace' => $this->_('Save and Replace'),
			'saveCopy' => $this->_('Save as Copy'), 
			'saving' => $this->_('Saving...'),
			'updating' => $this->_('Updating...'), 
			'yes' => $this->_('Yes'), 
			'noUse' => $this->_('No, use %s'),
			'caption' => $this->_('Caption?'), 
			'captionTip' => $this->_('Caption text is entered in the editor after you insert the image.'), 
			'captionText' => $this->_('Caption text here'),
			'rotateRight' => $this->_('Rotate to the right'), 
			'rotateLeft' => $this->_('Rotate to the left'), 
			'flipHorizontal' => $this->_('Flip horizontal'),
			'flipVertical' => $this->_('Flip vertical'),
			'noAccess' => $this->_('You do not have access to edit images on this page.'),
			'demoMode' => "Image editing functions are disabled in demo mode",
		);
	}

	/**
	 * Initialize and populate required variables from GET variables
	 * 
	 * @throws WireException
	 * 
	 */
	public function init() {
	
		$config = $this->wire()->config;
		$input = $this->wire()->input;
		$session = $this->wire()->session;
		$sanitizer = $this->wire()->sanitizer;
		$modules = $this->wire()->modules;
		$pages = $this->wire()->pages;
		$fields = $this->wire()->fields;
		$user = $this->wire()->user;
		
		// throw new WireException($this->labels['demoMode']);
		if($config->demo) {
			if($input->urlSegmentStr != 'variations') {
				throw new WireException($this->labels['demoMode']);
			}
		}
		
		$modules->get("ProcessPageList");
	
		$this->rte = $input->get('rte') !== null && $input->get('rte') == "0" ? false : true;
		$id = (int) $input->get('id'); 
		$editID = (int) $input->get('edit_page_id'); 
		
		if($editID) {
			$session->set($this, 'edit_page_id', $editID); 
		} else {
			$editID = (int) $session->get($this, 'edit_page_id'); 
		}
		
		if($editID) {
			$this->editorPage = $pages->get($editID); 
			if(!$this->editorPage->editable()) {
				$this->editorPage = null;
				$session->remove($this, 'edit_page_id'); 
			}
		}
	
		$fieldName = $sanitizer->fieldName($input->get('field')); 
		if($fieldName) {
			$this->fieldName = $fieldName; // original
			if(strpos($fieldName, '_repeater') && preg_match('/_repeater\d+$/', $fieldName, $matches)) {
				$fieldName = str_replace($matches[0], '', $fieldName);
			} else if(strpos($fieldName, '_LPID')) {
				list($fieldName, $lpid) = explode('_LPID', $fieldName);
				if($lpid) {} // ignore
			}
			$this->field = $fields->get($fieldName); 
			if(!$this->field) throw new WireException("Unknown field $fieldName"); 
			if(!$this->field->type instanceof FieldtypeImage) {
				if(!method_exists($this->field->type, 'getPageimages')) {
					throw new WireException("Field $fieldName is not an instance of FieldtypeImage or FieldtypeHasPageimages");
				}
			}
		}
		
		// if no ID was specified, then retrieive ID from filename path, if it's there
		$file = $input->get('file');
		if($file && preg_match('{[/,]}', $file)) {
			
			if(preg_match('{(\d+)[/,][^/,]+\.(' . $this->extensions . ')$}iD', $file, $matches)) {
				// ..........ID.../,filename.ext
				// format: 123/filename.jpg OR 123,filename.jpg
				// also covers new pagefileSecure URLs
				$id = (int) $matches[1]; 
				
			} else if(preg_match('{(/[\d/]+)/([-_.a-z0-9]+)\.(' . $this->extensions . ')$}iD', $file, $matches)) {
				// .................ID........filename........ext
				// extended asset path format: 1/2/3/filename.jpg
				$id = PagefilesManager::dirToPageID($matches[1]); 

			} else if(preg_match('{^' . $config->urls->root . '([-_./a-zA-Z0-9]*/)' . $config->pagefileUrlPrefix . '[^/]+\.(' . $this->extensions . ')$}iD', $file, $matches)) {
				// .............................../....................path/to/page.........................-?....................filename..........ext
				// legacy pagefileSecure URL format: /path/to/page/filename.jpg
				// @todo: does this still need to be here or can it be dropped?
				$this->page = $pages->get('/' . $matches[1]);
				$id = $this->page->id;
			}
		}
		
		if(!$id) throw new WireException("No page specified");
		if(!$this->page) $this->page = $this->pages->get($id); 
		if(!$this->page) throw new WireException("No page specified"); 
		if(!$this->page->id) throw new WireException("Unknown page"); 
		if(!$this->editorPage) $this->editorPage = $this->page;
		
		// if $this->page is a repeater item (for example), $this->masterPage is page it lives on
		$p = null;
		if(wireInstanceOf($this->page, 'RepeaterPage')) {
			/** @var RepeaterPage $p */
			$p = $this->page;
			while(wireInstanceOf($p, 'RepeaterPage')) {
				$p = $p->getForPage();
			}
		}
		$this->masterPage = $p && $p->id ? $p : $this->page; 
		
		// note we use hasPermission('page-view') rather than viewable() here because
		// we want to allow pages without template files
		if(!$user->hasPermission('page-view', $this->page)) {
			/** @var PagePermissions $pagePermissions */
			$pagePermissions = $modules->get('PagePermissions');
			if($this->page->id === $user->id && $fieldName && $pagePermissions->userFieldEditable($fieldName)) {
				// user editing allowed images field in their profile
			} else if(wireInstanceOf($this->page, 'RepeaterPage')) {
				if(!$this->masterPage->editable()) {
					throw new WireException($this->labels['noAccess']);
				}
			} else {
				throw new WireException($this->labels['noAccess']);
			}
		}

		if($input->get('winwidth')) $this->maxImageWidth = ((int) $input->get('winwidth')) - 70; 
		if($this->maxImageWidth < 400) $this->maxImageWidth = 400;

		$hidpi = $input->get('hidpi');
		if($hidpi === null && $this->hidpiDefault) $hidpi = true; 
		if(!$this->rte) $hidpi = false; // hidpi not applicable outside of RTE mode
		$this->hidpi = $hidpi ? true : false;
	
		if($this->rte) $this->caption = $input->get('caption') ? true : false; 
		
		// original used by InputfieldImage 
		$original = $input->get('original'); 
		if($original) {
			$original = $sanitizer->filename($original);
			if(is_file($this->page->filesManager()->path . $original)) {
				$this->original = $original;
			}
		}

		parent::init();
	}
	
	/**
	 * Return the Pageimage object being edited
	 * 
	 * @param bool $getVariation Returns the variation specified in the URL. Otherwise returns original (default). 
	 * @return Pageimage
	 * @throws WireException
	 * 
	 */
	public function getPageimage($getVariation = false) {

		$images = $this->getImages($this->page);
		$file = basename($this->input->get('file'));
		$variationFilename = '';
		
		if(strpos($file, ',') === false) {
			// prepend ID if it's not there, needed for ajax in-editor resize
			$originalFilename = $file;
			$file = $this->page->id . ',' . $file; 
		} else {
			// already has a "123," at beginning
			list($pageID, $originalFilename) = explode(',', $file);
			if($pageID) {} // ignore
		}
		
		$originalFilename = $this->wire()->sanitizer->filename($originalFilename, false, 1024);
		
		// if requested file does not match one of our allowed extensions, abort
		if(!preg_match('/\.(' . $this->extensions . ')$/iD', $file, $matches)) {
			throw new WireException("Unknown image file");
		}

		// get the original, non resized version, if present
		// format:            w x h    crop       -suffix
		if(preg_match('/(\.(\d+)x(\d+)([a-z0-9]*)(-[-_.a-z0-9]+)?)\.' . $matches[1] . '$/', $file, $matches)) {
			// filename referenced in $_GET['file'] IS a variation
			// Follows format: original.600x400-suffix1-suffix2.ext
			$this->editWidth = (int) $matches[2];
			$this->editHeight = (int) $matches[3];
			$variationFilename = $originalFilename; 
			$originalFilename = str_replace($matches[1], '', $originalFilename); // remove dimensions and optional suffix
		} else {
			// filename referenced in $_GET['file'] is NOT a variation
			$getVariation = false; 
		}

		// update $file as sanitized version and with original filename only
		$file = "{$this->page->id},$originalFilename"; 
		
		// if requested file is not one that we have, abort
		if(!array_key_exists($file, $images)) {
			throw new WireException("Cannot find image file '$originalFilename' on page: {$this->page->path}");
		}

		// return original
		if(!$getVariation) return $images[$file];
		
		// get variation
		$original = $images[$file]; 
		$variationPathname = $original->pagefiles->path() . $variationFilename; 
		$pageimage = null;
		if(is_file($variationPathname)) {
			$pageimage = $this->wire(new Pageimage($original->pagefiles, $variationPathname));
		}	
		if(!$pageimage) {
			throw new WireException("Unrecognized variation file: $file");
		}	
		
		return $pageimage; 
	}

	/**
	 * Get all Pageimage objects on page
	 * 
	 * @param Page $page
	 * @param array|WireArray $fields
	 * @param int $level Recursion level (internal use)
	 * @return array
	 * 
	 */
	public function getImages(Page $page, $fields = array(), $level = 0) {
		
		$allImages = array();
		if(!$page->id) return $allImages; 
		
		$numImages = 0;
		$numImageFields = 0;
		$skipFields = $this->wire()->input->urlSegment1 ? array() : explode(' ', $this->skipFields);
	
		if(empty($fields)) {
			if($this->field) {
				$fields = array($this->field);
				$skipFields = array();
			} else {
				$fields = $page->fields;
			}
		}

		foreach($fields as $field) {

			$fieldtype = $field->type;
			
			if(in_array($field->name, $skipFields)) continue; 

			if(wireInstanceOf($fieldtype, 'FieldtypeRepeater')) {
				// get images that are possibly in a repeater
				$repeaterValue = $page->get($field->name); 
				if($repeaterValue instanceof Page) $repeaterValue = array($repeaterValue); 
				if($repeaterValue) {
					foreach($repeaterValue as $p) {
						$images = $this->getImages($p, $p->fields, $level + 1);
						if(!wireCount($images)) continue;
						foreach($images as $image) {
							$parentFields = $image->get('_parentFields');
							if(!is_array($parentFields)) $parentFields = array();
							array_unshift($parentFields, $field); 
							$image->setQuietly('_parentFields', $parentFields); 
						}
						$allImages = array_merge($allImages, $images);
						$numImages += wireCount($images);
						$numImageFields++;
					}
				}
				continue; 
			}
			
			if($fieldtype instanceof FieldtypeImage) {
				$numImageFields++;
				$images = $page->getUnformatted($field->name);
			} else if(method_exists($fieldtype, 'getPageimages')) {
				/** @var FieldtypeHasPageimages $images */
				$numImageFields++;
				$images = $fieldtype->getPageimages($page, $field); 
			} else {
				continue;
			}
			
			if(!wireCount($images)) continue;

			foreach($images as $image) {
				$numImages++;
				$key = $page->id . ',' . $image->basename;  // page_id,basename for repeater support
				$allImages[$key] = $image; 
			}
		}

		if(!$level) {
			if(!$numImageFields) {
				$this->message($this->_("There are no image fields on this page. Choose another page to select images from.")); // Message when page has no image fields
			} else if(!$numImages) {
				$this->message($this->_("There are no images present on this page. Upload an image, or select images from another page."));  // Message when page has no images
			}
		}

		return $allImages; 
	}

	/**
	 * Get all editable image fields on the page
	 * 
	 * @param Page $page
	 * @param bool $excludeFullFields Exclude fields that are already full? (i.e. can't add more images to them)
	 * @return array of Field objects for image fields
	 * 
	 */
	public function getImageFields(Page $page, $excludeFullFields = true) {
		
		$skipFields = explode(' ', $this->skipFields);
		$imageFields = array();

		foreach($page->fields as $field) {
			/** @var Field $field */
			if(!$field->type instanceof FieldtypeImage) continue;
			if(in_array($field->name, $skipFields)) continue;
			if(!$page->editable($field->name)) continue; 
			if($excludeFullFields && $field->get('maxFiles') > 0) {
				$value = $page->get($field->name);
				if(wireCount($value) >= $field->get('maxFiles')) continue; 
			}
			$imageFields[$field->name] = $field;
		}
		return $imageFields; 
	}

	/**
	 * Default execute: display list of images on page for selection
	 * 
	 * @return string
	 * @throws WireException
	 * 
	 */
	public function ___execute() {

		if($this->config->demo) throw new WireException("Sorry, image editing functions are disabled in demo mode"); 
		
		if(!$this->page) {
			$error = "No page provided";
			$this->error($error);
			return "<p>$error</p>";
		}

		if($this->input->get('file')) return $this->executeEdit();
	
		$sanitizer = $this->wire()->sanitizer;
		$modules = $this->wire()->modules;
		$input = $this->wire()->input;
		$images = $this->getImages($this->page, $this->page->fields);

		$out = '';

		if(wireCount($images)) {
			$winwidth = (int) $input->get('winwidth');
			$in = $modules->get('InputfieldImage'); /** @var InputfieldImage $in */
			$in->set('adminThumbs', true);
			$lastFieldLabel = '';
			$numImageFields = 0;

			foreach($images as $image) {
				/** @var PageImage $image */
				$fieldLabels = array();
				$parentFields = $image->get('_parentFields');
				if(!is_array($parentFields)) $parentFields = array();
				foreach($parentFields as $parentField) {
					$fieldLabels[] = $parentField->getLabel();
				}
				$fieldLabels[] = $image->field->getLabel();
				$fieldLabel = implode(' > ', $fieldLabels);
				if($fieldLabel != $lastFieldLabel) {
					$numImageFields++;
					$out .= "\n\t<li class='select_images_field_label detail'>" . $sanitizer->entities($fieldLabel) . "</li>";
				}
				$lastFieldLabel = $fieldLabel;
				if($this->noThumbs) {
					$width = $image->width();
					$alt = $sanitizer->entities1($image->description); 
					if($width > $this->maxImageWidth) $width = $this->maxImageWidth;
					$img = "<img src='$image->URL' width='$width' alt=\"$alt\" />";
				} else {
					$image->set('_requireHeight', true); // recognized by InputfieldImage
					$info = $in->getAdminThumb($image); 
					$img = $info['markup'];
				}
				$out .= 
					"\n\t<li><a href='./edit?file={$image->page->id},{$image->basename}" . 
					"&amp;modal=1&amp;id={$this->page->id}&amp;winwidth=$winwidth'>$img</a></li>";
			}

			$class = $this->noThumbs ? "" : "thumbs";
			if($numImageFields > 1) $class = trim("$class multifield"); 
			$out = "\n<ul id='select_images' class='$class'>$out\n</ul>";

		}

		/** @var InputfieldForm $form */
		$form = $modules->get("InputfieldForm");
		$form->action = "./"; 
		$form->method = "get";

		/** @var InputfieldPageListSelect $field */
		$field = $modules->get("InputfieldPageListSelect"); 
		$field->label = $this->_("Images on Page:") . ' ' . $this->page->get("title") . " (" . $this->page->path . ")"; // Headline for page selection, precedes current page title/url
		$field->description = $this->_("If you would like to select images from another page, select the page below."); // Instruction on how to select another page
		$field->attr('id+name', 'page_id'); 
		$field->value = $this->page->id; 
		$field->parent_id = 0; 
		$field->collapsed = wireCount($images) ? Inputfield::collapsedYes : Inputfield::collapsedNo;
		$field->required = true; 
		$form->append($field);
		
		// locate any image fields
		$imageFields = $this->getImageFields($this->page);
		if(wireCount($imageFields)) {
			$imageFieldNames = implode(',', array_keys($imageFields)); 
			/** @var InputfieldButton $btn */
			$btn = $modules->get('InputfieldButton');
			$uploadOnlyMode = "$this->page" === "$this->editorPage" ? 1 : 2;
			$btn->href = "../edit/?modal=1&id={$this->page->id}&fields=$imageFieldNames&uploadOnlyMode=$uploadOnlyMode";
			$btn->value = $this->_('Upload Image');
			$btn->addClass('upload pw-modal-button pw-modal-button-visible'); 
			$btn->icon = 'upload';
			$changes = $input->get('changes'); 
			if($changes) {
				foreach(explode(',', $changes) as $name) {
					$name = $sanitizer->fieldName($name);
					$field = $this->wire()->fields->get($name);
					if(!$field) continue;
					$out .= "<script>refreshPageEditField('$name');</script>";
				}
			}
		} else $btn = null;

		$out = $form->render() . $out;
		if($btn) $out .= $btn->render();
		
		return "<div id='ProcessPageEditImageSelect'>" . $out . "\n</div>";
	}
	
	/**
	 * Given a Pageimage, return the closest/last cropped version of it
	 * 
	 * This is so that resizes can use the highest quality version to resize from. 
	 * 
	 * @param Pageimage $image
	 * @return Pageimage
	 * 
	 */
	protected function getLastCrop(Pageimage $image) {
		$info = $image->isVariation($image->name, true);
		if(!$info || !empty($info['crop'])) {
			return $image;
		}
		$cropName = null;
		$parentInfo = $info;
		while(!empty($parentInfo['parent'])) {
			$parentInfo = $parentInfo['parent'];
			if(!empty($parentInfo['crop'])) {
				$cropName = $parentInfo['name'];
				break;
			}
		}
		
		$original = $image->getOriginal();
		if(!$original) return $image; 
		if(filemtime($original->filename) > filemtime($image->filename)) return $image; 
		
		// if no last crop found, return original
		if(!$cropName) return $original;
		
		$variations = $original->getVariations();
		
		// return last found crop
		$cropImage = $variations->get($cropName);
		if($cropImage && filemtime($cropImage->filename) > filemtime($image->filename)) return $image;
		return $cropImage ? $cropImage : $original; 	
	}

	/**
	 * Make a image edit URL
	 * 
	 * @param string $file
	 * @param array $parts Additional variables you want to add
	 * @return string
	 * 
	 */
	protected function makeEditURL($file, $parts = array()) {
		$input = $this->wire()->input;
		
		$file = basename($file); 
		$id = isset($parts['id']) ? (int) $parts['id'] : $this->page->id; 
		
		$version = isset($parts['version']) ? (int) $parts['version'] : (int) $input->get('version');
		if($version > 0) $parts['version'] = $version;
		
		if(!isset($parts['modal'])) $parts['modal'] = 1;
		if(!isset($parts['edit_page_id']) && $this->editorPage) $parts['edit_page_id'] = $this->editorPage->id; 
		if(!isset($parts['hidpi'])) $parts['hidpi'] = (int) $this->hidpi; 
		if(!isset($parts['original'])) {
			if($this->original) {
				$parts['original'] = $this->original;
			} else {
				$parts['original'] = $file;
			}
		}
		if(!isset($parts['class']) && $input->get('class')) {
			$class = $input->get('class'); 
			if($class) {
				$validClasses = array_merge(
					explode(' ', $this->alignLeftClass), 
					explode(' ', $this->alignCenterClass), 
					explode(' ', $this->alignRightClass)
				);
				$classes = array();
				foreach(explode(' ', $class) as $c) {
					if(empty($c)) continue;
					if(in_array($c, $validClasses)) $classes[] = $c; 
				}
				if(count($classes)) $parts['class'] = urlencode(implode(' ' , $classes)); 
			}
		}
		if(!$this->rte) $parts['rte'] = '0';
		if($this->field) $parts['field'] = $this->fieldName; 
		
		if(!isset($parts['winwidth'])) {
			$winwidth = (int) $input->get('winwidth'); 
			if($winwidth) $parts['winwidth'] = $winwidth; 
		}
		
		if(!isset($parts['caption']) && $this->rte && $this->caption) {
			$parts['caption'] = 1; 
		}
		
		// @todo should we check for 'class' ?
		
		unset($parts['id'], $parts['file']); // in case they are set here
		
		$url = $this->wire()->config->urls->admin . "page/image/edit/?id=$id&file=$id,$file";
		
		foreach($parts as $key => $value) $url .= "&$key=$value";
		
		return $url;
	}

	/**
	 * Check if user has image edit permission and throw exception if they don't
	 *
	 * @param bool $throw Specify false if you only want this method to return a true|false rather than throw exception
	 * @throws WirePermissionException
	 * @return bool if the $throw argument was false
	 *
	 */
	public function checkImageEditPermission($throw = true) {
		if(!$this->rte && !$this->wire()->user->hasPermission('page-edit-images', $this->masterPage)) {
			if($throw) {
				throw new WirePermissionException($this->labels['noAccess']);
			} else {
				return false;
			}
		}
		return true;
	}

	/**
	 * Edit a selected image
	 * 
	 * Required GET variables:
	 * 	- file (string): URL of image, or preferably "123,basename.jpg" where 123 is ID. 
	 *  - id (int): ID of page images is from, optional if specified in 'file'.
	 *
	 * Optional GET variables:
	 * 	- edit_page_id (int): ID of the page being edited, where image will be placed.
	 * 	- width (int): Current width of image in the editor
	 * 	- height (int): Current height of image in the editor
	 * 	- winwidth (int): Current width of client-side window in pixels
	 * 	- id (int): ID of the page the image lives on (if omitted, we attempt to determine from 'file' path).
	 * 	- class (string): Class name to pass along to the editor (i.e. align_left, align_right, etc.)
	 * 	- hidpi (int): Whether hidpi mode is used or not (0=no, 1=yes)
	 * 	- link: (string): URL that image is linking to in the editor.
	 * 	- description: (string): Text for description "alt" tag. 
	 * 	- rte (int): Rich text editor mode (0=off, 1 or omit=on)
	 * 	- field (string): Name of field (for non-rte mode)
	 * 
	 * Optional GET variables that must come together as a group. When present, the image editor 
	 * will start in crop mode and show the original image with this portion selected for crop:
	 * 	- crop_x (int): Predefined crop left position
	 * 	- crop_y (int): Predefined crop top position
	 * 	- crop_w (int): Predefined crop width
	 * 	- crop_h (int): Predefined crop height
	 * 
	 * @return string
	 * @throws \Exception|WireException
	 * 
	 */
	public function ___executeEdit() {

		$input = $this->wire()->input;
		$config = $this->wire()->config;
		$session = $this->wire()->session;
		$sanitizer = $this->wire()->sanitizer;
		$adminTheme = $this->wire()->adminTheme;
		
		$checkboxClass = $adminTheme instanceof AdminThemeFramework ? $adminTheme->getClass('input-checkbox') : '';
		$radioClass = $adminTheme instanceof AdminThemeFramework ? $adminTheme->getClass('input-radio') : '';
		
		$this->checkImageEditPermission();

		if($input->post('submit_crop')) {
			$crop = $this->processCrop();
			$parts = $this->hidpi ? array('width' => $crop->hidpiWidth()) : array();
			$session->location($this->makeEditURL($crop->basename, $parts)); 
			return '';
		} else if($input->post('submit_save_replace')) {
			return $this->processSave(true); 
		} else if($input->post('submit_save_copy')) {
			return $this->processSave(false);
		}
	
		$path = $config->urls('ProcessPageEditImageSelect'); 
		$config->styles->add($path . 'cropper/cropper.min.css');
		$config->scripts->add($path . 'cropper/cropper.min.js'); 
	
		$labels =& $this->labels;
		$formClasses = array();
		
		$icons = array(
			'top' => 'long-arrow-up',
			'left' => 'long-arrow-left', 
			'width' => 'arrows-h', 
			'height' => 'arrows-v', 
		);
		foreach($icons as $key => $value) {
			$icons[$key] = "<i class='fa fa-fw fa-$value ui-priority-secondary'></i>";
		}
	
		$optionalClasses = array(
			$this->alignLeftClass => $labels['alignLeft'], 
			$this->alignRightClass => $labels['alignRight'], 
			$this->alignCenterClass => $labels['alignCenter'], 
		);

		$cropX = null; 
		$cropY = null; 
		$cropWidth = null; 
		$cropHeight = null;
		$isCropped = preg_match_all('/\.(\d+)x(\d+).*?-crop[xy](\d+)[xy](\d+)[-.]/', $input->get('file'), $matches); 
		
		if($isCropped) { // get last coordinates present
			$cropWidth = (int) array_pop($matches[1]);
			$cropHeight = (int) array_pop($matches[2]);
			$cropX = (int) array_pop($matches[3]); 
			$cropY = (int) array_pop($matches[4]); 
		}
		
		$isCroppable = !$isCropped;
		try {
			$original = $this->getPageimage(false);
		} catch(\Exception $e) {
			if($this->rte) {
				// when RTE mode, go to image selection again when invalid image
				$this->error($e->getMessage());
				$session->location("./?id={$this->page->id}");
				return '';
			} else {
				throw $e; 
			}
		}
		
		if($isCropped) {
			$image = $this->getPageimage(true); 
			$recropURL = $this->makeEditURL($original->basename, array(
				'crop_x' => $cropX,
				'crop_y' => $cropY, 
				'crop_w' => $cropWidth, 
				'crop_h' => $cropHeight
			));
		} else {
			$recropURL = "";
			$image = $original;
		}
		
		if(!is_file($image->filename)) throw new WireException("Image file does not exist"); 
		
		if($this->field) {
			$found = false;
			if($this->field->type instanceof FieldtypeImage) {
				$pageimages = $this->page->get($this->field->name);
				if($pageimages && $pageimages->get($original->name)) $found = true;
			} else if(method_exists($this->field->type, 'getPageimages')) {
				/** @var FieldtypeHasPageimages $fieldtype */
				$fieldtype = $this->field->type;
				$pageimages = $fieldtype->getPageimages($this->page, $this->field);
				foreach($pageimages as $pageimage) {
					if($pageimage->name === $original->name) $found = true;
					if($found) break;
				}
			}
			if(!$found) {
				throw new WireException("Please save the page before editing newly uploaded images.");
			}
		}
		
		$basename = $image->basename;
		$fullname = $image->page->id . ',' . $basename;  // "123,basename.jpg"

		$originalWidth = $image->width();
		$originalHeight = $image->height();

		
		// attributes for #selected_image
		$attrs = array(
			'class' => '',
			'width' => 0,
			'height' => 0,
			'data-origwidth' => $originalWidth,
			'data-origheight' => $originalHeight,
			'data-nosize' => ($this->noSizeAttrs ? '1' : ''),
		);
		
		if(!$this->rte && $this->field) {
			$minWidth = $this->field->get('minWidth') ? (int) $this->field->get('minWidth') : 1;
			$minHeight = $this->field->get('minHeight') ? (int) $this->field->get('minHeight') : 1;
		} else {
			$minWidth = 1; 
			$minHeight = 1;
		}
		
		// bundle in crop information to image attributes, if specified
		if($input->get('crop_x')) {
			$attrs['data-crop'] =
				((int) $input->get('crop_x')) . ',' .
				((int) $input->get('crop_y')) . ',' .
				((int) $input->get('crop_w')) . ',' .
				((int) $input->get('crop_h'));
		}

		// determine width/height we will display image at in editor, so that it fits
		$width = (int) $input->get('width'); 
		$height = (int) $input->get('height'); 
		if(!$width) $width = $this->editWidth; 
		if(!$width) $width = '';
		if(!$height) $height = $this->editHeight; 
		if(!$height) $height = '';
		
		// if width not specified in edit URL and image exceeds the window width, then scale to fit window
		if(!$width) {
			$width = $image->width; 
		}
		$attrs['width'] = $width;
		$attrs['data-fit'] = '1';
		$this->wire('processBrowserTitle', $basename); 

		// if they aren't already working with a resized image, and it's being scaled down,
		// then add the 'resized' class to ensure that our RTE 'pwimage' plugin knows to perform the resize
		// by checking for the 'resized' classname on the image
		if(basename($input->get('file')) == $fullname && $originalWidth > $width) $attrs['class'] .= " resized";
	
		// if hidpi specified keep it checed
		$hidpiChecked = $this->hidpi ? " checked='checked'" : "";
		$captionChecked = $this->caption ? " checked='checked'" : "";
	
		// alignment class options
		$classOptions = '';
		foreach($optionalClasses as $class => $label) {
			$labelKey = array_search($label, $labels); 
			$selected = strpos((string) $input->get('class'), $class) !== false ? " selected='selected'" : '';
			if($selected) $attrs['class'] .= " $class";
			$classOptions .= "<option$selected data-label='$labelKey' value='$class'>$label</option>";
		}
	
		// convert $attrs to an attribute string for placement in #selected_image markup
		$attrStr = ''; 
		foreach($attrs as $key => $value) {
			if(!$value && $key != 'data-nosize') continue; // skip most empty attributes
			$attrStr .= "$key='" . trim($value) . "' ";
		}

		// prepare description (alt)
		$description = isset($_GET['description']) ? $input->get('description') : ''; // $image->description;
		if(strlen($description) > 8192) $description = substr($description, 0, 8192);
		$description = $sanitizer->entities($description); 
		
		// if dealing with a variation size or crop provide the option to link to the original (larger)
		$linkOriginalChecked = '';
		if($image->name != $original->name || $input->get('link')) {
			if($image->name != $original->name) $formClasses[] = 'not-original';
			$imgURL = str_replace($config->urls->root, '/', $original->url);
			$imgLinkURL = trim((string) $input->get('link'));
			if($imgLinkURL) {
				if($imgLinkURL === "1") $imgLinkURL = $imgURL; // if in toggle mode, substitute URL
				// determine if 'link to original' checkbox should be checked 
				$test = substr($imgLinkURL, -1 * strlen($imgURL));
				$linkOriginalChecked = $test == $imgURL ? " checked='checked' data-was-checked='1'" : "";
				unset($imgLinkURL); 
			}
		} else {
			$formClasses[] = 'original';
		}
		
		$resizeYesChecked = $this->rte ? " checked='checked'" : "";
		$resizeNoChecked = !$this->rte ? " checked='checked'" : "";

		if($this->field && $this->field->get('maxFiles') == 1) $formClasses[] = 'maxfiles1';
			
		// form attributes
		$formClasses[] = $isCroppable ? 'croppable' : 'not-croppable';
		$formClasses[] = $this->rte ? 'rte' : 'not-rte';
		$formClass = implode(' ', $formClasses); 
		$formActionURL = $this->makeEditURL($basename);
		$originalDimension = $originalWidth . 'x' . $originalHeight; 
		$imageURL = $this->rte ? $image->url : $image->URL;

		// form header and inputs
		// @todo move all this markup to a separate template file
		$out =	
			"<form id='selected_image_settings' class='$formClass' method='post' action='$formActionURL'>" .
			"<small class='ui-helper-clearfix'>" . 
			"<p id='wrap_info'>" .
				"<select id='selected_image_class' name='class'>" .
					"<option value=''>" . $this->_('No Alignment') . "</option>" .
					$classOptions .
				"</select>" .
				"<span id='action_icons' class='hide_when_crop hide_when_processing'>" .
					"<span title='$labels[resize]' id='resize_action'><i class='fa fa-fw fa-arrows-alt'></i></span>" .
					"<span title='$labels[crop]' data-recrop='$recropURL' id='crop_action'><i class='fa fa-fw fa-crop'></i></span>" .
					//"<span title='$labels[rotateLeft]' id='rotate_left_action'><i class='fa fa-fw fa-rotate-left'></i></span>" . 
					//"<span title='$labels[rotateRight]' id='rotate_right_action'><i class='fa fa-fw fa-rotate-right'></i></span>" .
					//"<span title='$labels[flipHorizontal]' id='flip_horizontal_action'><i class='fa fa-fw fa-exchange'></i></span>" .
					//"<span title='$labels[flipVertical]' id='flip_vertical_action'><i class='fa fa-fw fa-exchange fa-rotate-90'></i></span>" .
					"<span title='$labels[max] ({$originalWidth}x{$originalHeight})' id='max_action'><i class='fa fa-fw fa-expand'></i></span>" .
					"<span title='$labels[min]' id='min_action'><i class='fa fa-fw fa-compress'></i></span>" .
					"<span title='$labels[alignLeft]' data-label='alignLeft' class='align_action show_when_rte' id='align_left_action'><i class='fa fa-fw fa-align-left'></i></span>" .
					"<span title='$labels[alignCenter]' data-label='alignCenter' class='align_action show_when_rte' id='align_center_action'><i class='fa fa-fw fa-align-center'></i></span>" .
					"<span title='$labels[alignRight]' data-label='alignRight' class='align_action show_when_rte' id='align_right_action'><i class='fa fa-fw fa-align-right'></i></span>" .
					"<span title='$labels[description]' id='description_action' class='show_when_rte'><i class='fa fa-fw fa-quote-left'></i></span>" .
				"</span>" .
				"<span id='selected_image_pixels' class='hide_when_crop'>" . 
					"<label><input title='$labels[width]' name='width' id='input_width' class='input_pixels' " .
						"type='number' size='6' step='1' data-min='$minWidth' data-max='$originalWidth' value='$width' /></label>" .
					"&nbsp;<i class='fa fa-times'></i>&nbsp;" . 
					"<label><input title='$labels[height]' name='height' id='input_height' class='input_pixels' " . 
						"type='number' size='6' step='1' data-min='$minHeight' data-max='$originalHeight' value='$height' /></label>" . 
				"</span> " .
				"<span id='selected_image_checkboxes' class='hide_when_crop'>" . 
					"<span id='wrap_link_original' class='show_when_rte'>" .
						"<label class='checkbox'>" .
							"<input title='$labels[linkOriginal]' type='checkbox' class='$checkboxClass' $linkOriginalChecked " .
								"name='selected_image_link' id='selected_image_link' value='$original->url' /> " .
							"<i class='fa fa-link ui-priority-secondary'></i>&nbsp;" .
							"{$original->width}x{$original->height}" .
						"</label>" .
					"</span>" . 
					"<span id='wrap_caption' class='show_when_rte'>" .
						"<label class='checkbox'>" .
							"<input$captionChecked id='selected_image_caption' type='checkbox' class='$checkboxClass' value='1' title='$labels[captionTip]' />&nbsp;$labels[caption]" .
						"</label>" .
					"</span>" . 
					"<span id='wrap_hidpi' class='show_when_rte'>" .
						"<label class='checkbox'>" . 
							"<input$hidpiChecked id='selected_image_hidpi' type='checkbox' class='$checkboxClass' value='1' />&nbsp;$labels[hidpi]" . 
						"</label>" .
					"</span>" .
				"</span>" . // selected_image_checkboxes
				"<span id='selected_image_resize' class='hide_when_rte hide_when_crop hide_when_processing'>" .
					"<i class='fa fa-angle-left ui-priority-secondary'></i>&nbsp;" .
					"$labels[useResize]&nbsp;&nbsp;" .
					"<label class='checkbox'><input$resizeYesChecked id='selected_image_resize_yes' name='use_resize' type='radio' class='$radioClass' value='1' />&nbsp;$labels[yes]</label>&nbsp;&nbsp;" .
					"<label class='checkbox'><input$resizeNoChecked id='selected_image_resize_no' name='use_resize' type='radio' class='$radioClass' value='0' />&nbsp;" .
					sprintf($labels['noUse'], $originalDimension) . "</label>" .
				"</span>" . 
				"<button type='button' class='ui-button ui-state-active show_when_processing' id='button_saving'>" .
					"<span class='ui-button-text'><i class='fa fa-spin fa-spinner'></i> $labels[saving]</span>" .
				"</button> " .
				"<span class='show_when_crop'>" .
					"<button type='submit' class='ui-button ui-state-default hide_when_processing' id='button_crop' name='submit_crop' value='1'>" .
						"<span class='ui-button-text'><i class='fa fa-crop'></i> $labels[saveCrop]</span>" .
					"</button> " .
					"<button type='button' class='hide_when_processing ui-button ui-state-default ui-priority-secondary' id='button_cancel_crop'>" .
						"<span class='ui-button-text'><i class='fa fa-times-circle'></i> $labels[cancel]</span>" .
					"</button> &nbsp; " . 
					"<span id='crop_coordinates'>" . 
						"<label>$labels[left]&nbsp;<input name='crop_x' id='crop_x' class='input_pixels' type='number' size='6' step='1' min='0' /></label>&nbsp; " .
						"<label>$labels[top]&nbsp;<input name='crop_y' id='crop_y' class='input_pixels' type='number' size='6' step='1' min='0' /></label>&nbsp; " .
						"<label>$labels[width]&nbsp;<input name='crop_w' id='crop_w' class='input_pixels' type='number' size='6' step='1' min='0' /></label>&nbsp; " .
						"<label>$labels[height]&nbsp;<input name='crop_h' id='crop_h' class='input_pixels' type='number' size='6' step='1' min='0' /></label>&nbsp; " .
					"</span>" . 
				"</span>" .
				"<button type='button' id='loading_button' class='ui-button ui-state-default'>" .
					"<span class='ui-button-text'>" . $this->_('Loading...')  ."</span>" .
				"</button>" . 
			"</p>" . 
			"<p id='wrap_description' class='show_when_rte'>" . 
				"<label for='selected_image_description'>$labels[description]</label>" . 
				"<input type='text' name='selected_image_description' " . 
					"id='selected_image_description' title='$description' class='InputfieldNoFocus' value=\"$description\" />" . 
			"</p>" . 
			"</small>" . 
			//"<p class='notes hide_when_rte'>Please note: this feature is still in development.</p>" . 
			"<figure id='selected_image_container'>" . 
				"<img id='selected_image' $attrStr alt='' " . 
					"data-idname='{$image->page->id},{$image->basename}' src='{$imageURL}' />" . 
				"<span id='resize_tips'>" .
					"<span class='resize_tip' id='resize_tip_nw'><i class='fa fa-fw fa-expand fa-flip-horizontal'></i></span>" .
					"<span class='resize_tip' id='resize_tip_n'><i class='fa fa-fw fa-arrows-v'></i></span>" .
					"<span class='resize_tip' id='resize_tip_ne'><i class='fa fa-fw fa-expand'></i></span>" .
					"<span class='resize_tip' id='resize_tip_e'><i class='fa fa-fw fa-arrows-h'></i></span>" .
					"<span class='resize_tip' id='resize_tip_se'><i class='fa fa-fw fa-expand fa-flip-horizontal'></i></span>" .
					"<span class='resize_tip' id='resize_tip_s'><i class='fa fa-fw fa-arrows-v'></i></span>" .
					"<span class='resize_tip' id='resize_tip_sw'><i class='fa fa-fw fa-expand'></i></span>" .
					"<span class='resize_tip' id='resize_tip_w'><i class='fa fa-fw fa-arrows-h'></i></span>" . 
				"</span>" .
				"<figcaption id='caption_preview' class='show_when_rte'>" . 
					"<a class='tooltip' title='$labels[captionTip]'>$labels[captionText]</a>" . 
				"</figcaption>" . 
			"</figure>" .
			"<p id='latin'>" . 
				"<span id='latin-fade'></span>" . $this->getLatin(2) . 
			"</p>" . 
			"<span id='selected_image_filename'>$basename</span>" .
			"<input type='hidden' name='page_id' id='page_id' value='{$this->page->id}' />" .
			"<input type='hidden' name='selected_image_rotate' id='selected_image_rotate' value='0' />" .
			"<p id='non_rte_dialog_buttons' class='hide_when_rte'>" . 
				(!in_array('maxfiles1', $formClasses) ? (
					"<button type='submit' name='submit_save_copy' value='1' class='submit_save_copy ui-button ui-state-default hide_when_maxfiles1'>" .
						"<span class='ui-button-text'><i class='fa fa-fw fa-paste'></i> $labels[saveCopy]</span>" .
					"</button>" 
					) : "") . 
				"<button type='submit' name='submit_save_replace' value='1' class='submit_save_replace ui-button ui-state-default'>" .
					"<span class='ui-button-text'><i class='fa fa-fw fa-cut'></i> $labels[saveReplace]</span>" .
				"</button>" .
				"<button type='button' id='non_rte_cancel' class='ui-button ui-state-default ui-priority-secondary'>" . 
					"<span class='ui-button-text'><i class='fa fa-fw fa-times-circle'></i> $labels[cancel]</span>" . 
				"</button>" . 
			"</p>"  .
			"</form>";
		
		// if($this->wire('config')->debug) $out .= "<p class='detail'>$basename</p>";
		
		return $out; 
		
	}

	/**
	 * Get a bunch of latin text
	 * 
	 * @param int $n How many blocks of latin to get (default=1)
	 * @return string
	 * 
	 */
	protected function getLatin($n = 1) {
		$latin = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. In malesuada magna ex. Donec sed consectetur felis, 
			ac molestie massa. Duis fermentum rutrum vehicula. Praesent consequat efficitur dolor quis vestibulum.
			Sed vulputate, nisi et efficitur semper, erat odio scelerisque massa, nec convallis leo ipsum ac eros. Donec a 
			augue eleifend, pellentesque sapien a, tristique orci. Sed ac leo vel libero iaculis facilisis in at mauris.
			Etiam vitae mi cursus nisl gravida vulputate. Nam in magna risus. Nullam vel suscipit lacus. Ut sit amet malesuada 
			massa. Etiam rhoncus, tellus vel porta dignissim, elit massa cursus tortor, vitae fermentum dolor augue ac tortor.
			Aliquam eleifend mi at dictum pulvinar. Sed bibendum dolor non mi placerat, euismod euismod est commodo.
			Vivamus ultrices orci arcu, sed pulvinar tellus molestie quis. Quisque pharetra velit a metus mattis, vitae 
			fermentum orci hendrerit. Fusce id libero ac urna ultrices consequat. Donec lorem lacus, dignissim vitae faucibus 
			eget, tincidunt sit amet felis. Aenean tempor quis sapien vitae euismod. Donec mattis mi at sem egestas interdum.
			Integer sed diam finibus, volutpat lectus ac, ornare dui. Suspendisse potenti. Suspendisse sagittis interdum 
			elit sodales commodo. Maecenas porttitor mi sit amet velit porta, sit amet eleifend massa malesuada. Phasellus 
			luctus nibh id sem venenatis, dictum fermentum turpis hendrerit. Nam tempor, ex eu sagittis gravida, mi lorem 
			maximus ex, aliquam commodo nunc leo et lacus. Nunc vel faucibus lacus, non pellentesque elit. Nulla non rutrum 
			magna, nec porta augue. ";
		$out = '';
		for($c = 1; $c <= $n; $c++) $out .= $latin;
		return $out; 
	}

	/**
	 * Process an image crop from submitted POST vars
	 * 
	 * @return Pageimage
	 * @throws WireException
	 * 
	 */
	protected function processCrop() {
	
		$input = $this->wire()->input;
		
		$cropX = (int) $input->post('crop_x');
		$cropY = (int) $input->post('crop_y'); 
		$cropW = (int) $input->post('crop_w');
		$cropH = (int) $input->post('crop_h'); 
		
		$image = $this->getPageimage();
		if(!$image) throw new WireException("Unable to load image");

		
		$suffix = $this->rte ? array('is') : array();
		$options = array('cleanFilename' => true, 'suffix' => $suffix);
		$crop = $image->crop($cropX, $cropY, $cropW, $cropH, $options); 
		
		return $crop; 
	}

	/**
	 * Save resized image and existing eisting or create a copy
	 * 
	 * @param bool $replace Replace existing image? (default=false)
	 * @return string Returns status output which may appear momentarily while dialog updating
	 * @throws WireException
	 * 
	 */
	protected function processSave($replace = false) {
		
		if(!$this->field) throw new WireException("Field is not defined"); 
		if(!$this->original) throw new WireException("Original is not set");
		if($this->page->hasStatus(Page::statusLocked)) throw new WireException("Page is locked for edits");

		$pages = $this->wire()->pages;
		$input = $this->wire()->input;
		$fieldtype = $this->field->type;
		$image = $this->getPageimage(false);
		
		if($input->get('file') != "$this->page,$image->name") $image = $this->getPageimage(true); 
		
		$width = (int) $input->post('width');
		if(!$width) throw new WireException("Width not specified"); 

		$rebuildVariations = preg_match('/-cropx\d+y\d+/', $image->name); 
		$useResize = ((int) $input->post('use_resize')) == 1;
		
		// image2 = resized version
		if($useResize) {
			$image2 = $image->width($width);
			if(!$image2 || !$image2->width) throw new WireException('Unable to complete resize');
		} else {
			$image2 = $image; 
		}
	
		/** @var Pageimages $pageimages */
		if($fieldtype instanceof FieldtypeImage) {
			$pageimages = $this->page->getUnformatted($this->field->name);
		} else {
			$pageimages = $image->pagefiles;
		}
		
		$path = $pageimages->path();
		$fileID = '';
		$isNew = 0;
		$body = '';

		if($replace) {
			// replace original image
			
			if($this->original && $this->original != $image2->basename() && $original = $image->pagefiles->get($this->original)) {
				/** @var Pageimage $original */	
				
				$fileID = 'file_' . $original->hash; 
				
				if($rebuildVariations && $this->field->get('adminThumbs')) {
					// remove original thumbnail
					/** @var InputfieldImage $inputfield */
					$inputfield = $this->field->getInputfield($this->page);
					if($inputfield) {
						$thumb = $inputfield->getAdminThumb($original);
						$thumb = $thumb['thumb'];
						if($thumb->url != $original->url) {
							// there is a thumbnail, distinct from the original image
							$this->wire()->files->unlink($thumb->filename);
						}
					}
				}

				
				// replace original image
				if($original->replaceFile($image2->filename())) {
					$original->modified = time();
					$original->modifiedUser = $this->wire()->user;
					/** @var FieldtypeFile $fieldtype */
					if($fieldtype instanceof FieldtypeFile) {
						$fieldtype->saveFileCols($this->page, $this->field, $original, array(
							'filesize' => $original->filesize(),
							'modified' => $original->modified,
							'modified_users_id' => $original->modified_users_id,
							'width' => $original->width(),
							'height' => $original->height(),
							'ratio' => $original->ratio(),
						));
					} else {
						$this->page->trackChange($this->field->name);
					}
				}
			
				$pages->uncacheAll();	
				$page = $pages->get($this->page->id); 
				
				/** @var Pageimages $value */
				$value = $page->getUnformatted($this->field->name); 
				if(!$value instanceof Pageimages) $value = $pageimages;
				
				if($rebuildVariations) {
					/** @var Pageimage $finalImage */
					$finalImage = $value->get($this->original);
					$variationInfo = $finalImage->rebuildVariations(0, array('is', 'hidpi'));
					foreach($variationInfo as $type => $files) {
						if(!wireCount($files)) continue;
						$body .= "<h3>" . ucfirst($type) . "</h3>";
						$body .= "<p>" . implode('<br />', $files) . "</p>";
						// $this->wire('log')->save('images', "$type: [ " . implode(' ], [ ', $files) . ' ]');
					}
				} else {
					$body .= "<p>" . $this->_('No changes necessary to image variations.') . "</p>";
				}
				
				$headline = $this->labels['updating'];
				
			} else {
				$headline = $this->_('No changes made'); 
			}

		} else {
			// save a new copy
			
			$n = 0;
			do {
				$n++;
				$basename = basename($this->original); 
				preg_match('/^([^.]+)(\..+)$/', $basename, $matches);
				$basename = $matches[1];
				$aftername = $matches[2];
				if(strpos($basename, '-v')) $basename = preg_replace('/-v[0-9]+$/', '', $basename);
				$basename .= "-v$n";
				$basename .= $aftername;
				$pathname = $path . $basename;
			} while(is_file($pathname));
			
			if(!$useResize && !$rebuildVariations) {
				$this->wire()->files->copy($image2->filename(), $pathname);
			} else {
				$this->wire()->files->rename($image2->filename(), $pathname);
			}
			$pageimages->add($pathname);
			$pageimage = $pageimages->last(); /** @var Pageimage $pageimage */
			if(!$this->field->get('overwrite')) $pageimage->isTemp(true);
			$this->page->save($this->field->name);
			$fileID = "file_$pageimage->hash";
			$isNew = 1; 
			$headline = $this->labels['updating'];
		}

		$js = 'script';
		$out = "
			<h2><i class='fa fa-spin fa-spinner fa-2x fa-fw ui-priority-secondary'></i> $headline</h2>
			$body
			<$js>$(document).ready(function() { setupProcessSave('$this->fieldName', '$fileID', $isNew); });</$js>
			";
		
		return $out; 
	}

	/**
	 * Resize image and output basic markup with information about the resize
	 * 
	 * The output is intended to be read by the rich text editor for insertion into the text. 
	 * 
	 * Required GET variables: 
	 * 	- file (string): URL of image
	 * 	- width (int): Target resize width
	 * 	- edit_page_id (int): ID of the page being edited, where image will be placed. 
	 * 
	 * Optional GET variables:
	 * 	- id (int): ID of the page the image lives on (if omitted, we attempt to determine from 'file' path). 
	 * 	- class (string): Class name to pass along to the editor (i.e. align_left, align_right, etc.) 
	 * 	- hidpi (int): Whether hidpi mode is used or not (0=no, 1=yes)
	 * 	- json (int): Specify 1 to provide output in JSON (if not specified, output is a paragraph of markup)
	 * 
	 * @return string
	 * @throws WireException
	 * 
	 */
	public function ___executeResize() {

		$this->checkImageEditPermission();
		
		$input = $this->wire()->input;
		$adminTheme = $this->wire()->adminTheme;
		$checkboxClass = $adminTheme instanceof AdminThemeFramework ? $adminTheme->getClass('input-checkbox') : '';

		$width = (int) $input->get('width'); 
		$class = $this->sanitizer->name($input->get('class'));
		$hidpi = $this->hidpi;
		$json = (int) $input->get('json'); // 1 or 0 (for json output mode on or off)
		$rotate = (int) $input->get('rotate');
		$flip = $input->get('flip'); 
		$crop = $input->get('crop'); 
		
		if($flip != 'v' && $flip != 'h') $flip = '';
		if($crop !== null && (!ctype_alnum($crop) || strlen($crop) > 20)) $crop = '';
		
		if(strpos($class, 'hidpi') !== false) {
			if(!$hidpi) $hidpi = true; 
		} else {
			if($hidpi) $class = trim("$class hidpi");
		}
		
		$image = $this->getPageimage(true);
		
		if(!$crop && strpos($image->basename, '-cropx') !== false) {
			$image = $this->getLastCrop($image);
		}
		
		if(	(!$hidpi && $width < $image->width) || 
			($hidpi && $width < $image->hidpiWidth()) ||
			$flip || $rotate) {
			
			$suffix = array('is');  // is=image select
			if($this->editorPage && $this->editorPage->id != $this->page->id) {
				$suffix[] = "pid$this->editorPage"; // identify page that is using the variation
			}
			$options = array(
				'suffix' => $suffix,
				'hidpi' => $hidpi,
			);
			if($rotate) $options['rotate'] = $rotate; 
			if($flip) $options['flip'] = $flip;
			if($crop) $options['cropping'] = $crop;
			//$this->log(print_r($options, true)); 
			$resized = $image->width($width, $options); 
			$height = $resized->height();
		} else {
			$width = $image->width; 
			$height = $image->height; 
			$resized = $image; 
		}
	
		if($hidpi) {
			$width = $resized->hidpiWidth();
			$height = $resized->hidpiHeight();
			$hidpiChecked = " checked='checked'";
		} else {
			$hidpiChecked = '';
		}
		
		$nosize = $this->noSizeAttrs ? 1 : '';
		
		if($json) {
			
			$data = array(
				'src' => $resized->url,
				'name' => $image->basename, 
				'width' => $width,
				'height' => $height, 
				'class' => $class, 
				'page' => $image->page->id,
				'hidpi' => (bool) $hidpi,
				'nosize' => (bool) $nosize, 
			);
			
			header("Content-Type: application/json"); 
			$out = json_encode($data); 
			
		} else {
			// note IE8 won't properly read the width/height attrs via ajax
			// so we provide the width/height in separate fields

			$out =
				"<p>" .
				"<span id='selected_image_width'>$width</span>x" .
				"<span id='selected_image_height'>$height</span> " .
				"<label><input type='checkbox' class='$checkboxClass' id='selected_image_hidpi' $hidpiChecked />hidpi</label><br />" .
				"<img alt='' " . 
					"id='selected_image' " . 
					"class='$class' " . 
					"data-idname='{$image->page->id},{$image->basename}' " . 
					"data-nosize='$nosize' " . 
					"src='$resized->url' " . 
					"width='$width' " . 
					"height='$height' " .
				"/>" . 
				"</p>";
		}
		//$this->log($out); 

		return $out; 
	}

	/**
	 * Show all variations for the image provided in GET var 'file'
	 * 
	 * @return string
	 * @throws WireException
	 * 
	 */
	public function ___executeVariations() {

		$files = $this->wire()->files;
		$modules = $this->wire()->modules;
		$user = $this->wire()->user;
		$input = $this->wire()->input;
		$sanitizer = $this->wire()->sanitizer;
		$pages = $this->wire()->pages;
		$config = $this->wire()->config;
		$pageimage = $this->getPageimage();
		
		if(!$this->page || !$pageimage) throw new WireException("No file provided"); 
		if(!$this->masterPage->editable()) throw new WireException($this->labels['noAccess']);
		
		$cnt = 0; // for id purposes
		$num = 0; // for display purposes
		$rows = array();
		$name = $pageimage->basename();
		$filesize = $pageimage->filesize();
		$filesizeStr = wireBytesStr($filesize);
		$mtime = filemtime($pageimage->filename);
		$modified = date('Y-m-d H:i:s', $mtime);
		$url = $pageimage->url() . "?nc=$mtime";
		$originalLabel = $this->_('Original');
		$extraLabel = $this->_('%s of above'); 
		$hasEditPermission = $user->hasPermission('page-edit-images', $this->masterPage);
		$variations = $pageimage->getVariations(array('info' => true, 'verbose' => 1));
		$adminThumbOptions = $config->adminThumbOptions;
		$delete = $input->post('delete');
		
		if(is_array($delete) && count($delete) && $hasEditPermission) {
			$deleteUrls = array();
			$deleteErrors = array();
			$extras = array();

			foreach($pageimage->extras() as $extra) $extras[] = $extra;
			foreach($variations as $v) {
				foreach($v['pageimage']->extras() as $extra) $extras[] = $extra;
			}
			foreach($extras as $extra) {
				if($extra->exists()) $variations[$extra->basename()] = array(
					'path' => $extra->filename(),
					'url' => $extra->url()
				);
			}

			foreach($delete as $name) {
				if(!isset($variations[$name])) continue; 
				$info = $variations[$name]; 
				if($files->exists($info['path']) && $files->unlink($info['path'])) {
					$deleteUrls[] = $info['url'];
					if(!empty($info['webpPath']) && $files->exists($info['webpPath'])) {
						if($files->unlink($info['webpPath'])) {
							$deleteUrls[] = $info['webpUrl'];
						} else {
							$deleteErrors[] = $info['webpUrl'];
						}
					}
					unset($variations[$name]); 
				} else {
					$deleteErrors[] = $info['url'];
				}
			}
			foreach($deleteUrls as $url) {
				$this->message($this->_('Deleted image variation') . " - $url");
			}
			foreach($deleteErrors as $url) {
				$this->error($this->_('Error deleting image variation') . " - $url"); 
			}
			$this->wire()->session->location("./?id={$this->page->id}&file=$pageimage->basename");
		}
		
		$rows[] = array(
			'cnt' => $cnt,
			'num' => $num, 
			'url' => $url,
			'name' => $name,
			'notes' => array($originalLabel),
			'width' => $pageimage->width(),
			'height' => $pageimage->height(),
			'modified' => $modified,
			'filesize' => $filesize,
			'filesizeStr' => $filesizeStr,
			'deletable' => $hasEditPermission,
		);

		foreach($pageimage->extras() as $extra) {
			if(!file_exists($extra->filename)) continue;
			$name = $extra->basename();
			$filesize = $extra->filesize();
			$filesizeStr = wireBytesStr($filesize);
			$mtime = filemtime($extra->filename);
			$modified = date('Y-m-d H:i:s', $mtime);
			$url = $extra->url() . "?nc=$mtime";
			$ext = strtoupper($extra->ext);
			$rows[] = array(
				'cnt' => ++$cnt,
				'num' => "$num <span class='detail'>$ext</span>",
				'url' => $url,
				'name' => $name,
				'notes' => array(sprintf($extraLabel, $ext) . " ($extra->savingsPct)"),
				'width' => $pageimage->width(),
				'height' => $pageimage->height(),
				'modified' => $modified,
				'filesize' => $filesize,
				'filesizeStr' => $filesizeStr,
				'deletable' => $hasEditPermission,
			);
		}
		
		foreach($variations as $name => $info) {
		
			$notes = array();
			if(in_array('is', $info['suffix'])) $notes[] = $this->_x('Created for placement in textarea', 'notes'); 
			if(in_array('hidpi', $info['suffix'])) $notes[] = $this->_x('HiDPI/Retina', 'notes'); 
			if(!count($notes) && $info['width'] == $adminThumbOptions['width'] && $info['height'] == $adminThumbOptions['height']) {
				$notes[] = $this->_x('Auto-generated admin thumbnail', 'notes'); 
			}
			if(!count($notes)) $notes[] = $this->_x('API-generated variation', 'notes');
			if($info['crop']) $notes[] = $this->_x('Cropped version', 'notes');
		
			// identify pid suffixes
			foreach($info['suffix'] as $suffix) {
				if(strpos($suffix, 'pid') === 0) {
					$suffix = ltrim($suffix, 'pid');
					$refpage = null;
					if(ctype_digit($suffix)) $refpage = $pages->get((int) $suffix);
					if($refpage && $refpage->id && $user->hasPermission('page-view', $refpage)) {
						$notes[] = $this->_x('Inserted from page:', 'notes') . " <a target='_blank' href='$refpage->url'>$refpage->path</a>";
					}
				}
			}
		
			$width = (int) $info['width'];
			$height = (int) $info['height'];
			if(!$width || !$height) list($width, $height) = getimagesize($info['path']);
			$filesize = filesize($info['path']); 
			$filesizeStr = wireBytesStr($filesize);
			$mtime = filemtime($info['path']); 
			$modified = date('Y-m-d H:i:s', $mtime); 
			$url = "$info[url]?nc=$mtime";
		
			$rows[] = array(
				'cnt' => ++$cnt, 
				'num' => ++$num, 
				'url' => $url, 
				'name' => $name,
				'notes' => $notes, 
				'width' => $width,
				'height' => $height,
				'modified' => $modified, 
				'filesize' => $filesize,
				'filesizeStr' => $filesizeStr, 
				'deletable' => $hasEditPermission,
			);
			
			/** @var Pageimage $pi */
			$pi = $info['pageimage'];
			foreach($pi->extras() as $extra) {
				if(!$extra->exists()) continue;
				$name = $extra->basename();
				$filesize = $extra->filesize();
				$filesizeStr = wireBytesStr($filesize);
				$mtime = filemtime($extra->filename());
				$modified = date('Y-m-d H:i:s', $mtime);
				$url = $extra->url() . "?nc=$mtime";
				$ext = strtoupper($extra->ext);
				
				$rows[] = array(
					'cnt' => ++$cnt,
					'num' => "$num <span class='detail'>$ext</span>", 
					'url' => $url,
					'name' => $name, 
					'notes' => array(sprintf($extraLabel, $ext) . " ($extra->savingsPct)"),
					'width' => $width,
					'height' => $height,
					'modified' => $modified,
					'filesize' => $filesize,
					'filesizeStr' => $filesizeStr,
					'deletable' => $hasEditPermission,
				);
			}
		}

		/** @var InputfieldCheckbox $checkbox */
		$checkbox = $modules->get('InputfieldCheckbox');
		$checkbox->label = ' ';
		$checkbox->addClass('delete');
		$checkbox->attr('id+name', 'delete_all');
		$checkbox->val(1);

		/** @var MarkupAdminDataTable $table */
		$table = $modules->get('MarkupAdminDataTable');
		$table->setEncodeEntities(false);
		$table->headerRow(array(
			'#',
			$this->_x('Image', 'th'),
			$this->_x('File', 'th'),
			$this->_x('Size', 'th'),
			$this->_x('Modified', 'th'),
			$this->_x('Notes', 'th'),
			($hasEditPermission ? $checkbox->render() : "&nbsp;")
		));
		
		foreach($rows as $row) {
			$checkbox->attr('name', 'delete[]');
			$checkbox->val($row['name']);
			$checkbox->attr('id', "delete_$row[cnt]"); 
			$table->row(array(
				(strlen($row['num']) ? $row['num'] : '&nbsp;'),
				"<a class='preview' href='$row[url]'><img src='$row[url]' alt='$row[name]' style='max-width: 100px;' /></a>",
				"<a class='preview' href='$row[url]'>$row[name]</a><br /><span class='detail'>$row[width]x$row[height]</span>",
				"<span style='display: none;'>$row[filesize] </span>$row[filesizeStr]",
				$row['modified'],
				implode('<br />', $row['notes']),
				($row['cnt'] && $row['deletable'] ? $checkbox->render() : "&nbsp;")
			));
		}
		
		$this->headline(sprintf(
			$this->_n('%1$d variation for image %2$s', '%1$d variations for image %2$s', $num),
			$num, $pageimage->basename
		));

		$varcnt = $sanitizer->entities($input->get('varcnt')); 

		/** @var InputfieldForm $form */
		$form = $modules->get('InputfieldForm');
		$form->attr('id', 'ImageVariations'); 
		$form->action = "./?id={$this->page->id}&file=$pageimage->basename&varcnt=$varcnt";
		$form->prependMarkup = $table->render();

		if($hasEditPermission) {
			/** @var InputfieldSubmit $submit */
			$submit = $modules->get('InputfieldSubmit');
			$submit->attr('value', $this->_('Delete Checked'));
			$submit->addClass('delete-checked');
			$submit->icon = 'trash';
			$form->add($submit);
		}
	
		/** @var InputfieldButton $button */
		$button = $modules->get('InputfieldButton'); 
		$button->attr('value', $this->_('Close')); 
		$button->addClass('pw-modal-cancel'); 
		$button->icon = 'times-circle';
		$form->add($button); 
	
		/** @var InputfieldHidden $hidden */
		$hidden = $modules->get('InputfieldHidden');
		$hidden->attr('id+name', 'varcnt_id'); 
		$hidden->attr('value', $varcnt); 
		$hidden->attr('data-cnt', wireCount($variations)); 
		$form->add($hidden); 
		
		$modules->get('JqueryMagnific'); 
		
		$out = $form->render(); 
		
		if($config->demo) {
			$out = "<p class='detail'>Note: " . $this->labels['demoMode'] . "</p>" . $out;
		} else {
			$out = "<br />" . $out;
		}
		
		return $out; 
		
	}

	/**
	 * Module configuration
	 * 
	 * @param array $data
	 * @return InputfieldWrapper
	 * 
	 */
	public function getModuleConfigInputfields(array $data) {
		$inputfields = $this->wire(new InputfieldWrapper());
		$data = array_merge(self::$defaultConfig, $data); 
		$modules = $this->wire()->modules;

		/** @var InputfieldCheckbox $f */
		$f = $modules->get('InputfieldCheckbox'); 
		$f->attr('name', 'hidpiDefault'); 
		$f->label = $this->_('HiDPI/Retina checkbox default checked?'); 
		$f->description = $this->_('Check this box to have the HiDPI/Retina checkbox checked by default for newly inserted images.'); 
		if(!empty($data['hidpiDefault'])) $f->attr('checked', 'checked'); 
		$f->columnWidth = 50;
		$inputfields->add($f);

		/** @var InputfieldCheckbox $f */
		$f = $modules->get('InputfieldCheckbox'); 
		$f->attr('name', 'noThumbs'); 
		$f->label = $this->_('Do not generate thumbnail images'); 
		$f->description = $this->_('When checked, image selection will use full-size images rather than thumbnail images.'); 
		if(!empty($data['noThumbs'])) $f->attr('checked', 'checked');
		$f->columnWidth = 50;
		$inputfields->add($f);

		/** @var InputfieldText $f */
		$f = $modules->get('InputfieldText'); 
		$f->attr('name', 'skipFields'); 
		$f->attr('value', isset($data['skipFields']) ? $data['skipFields'] : ''); 
		$f->label = $this->_('Field names to skip for selection'); 
		$f->description = $this->_('Enter the names of any image fields (separated by a space) that you do not want to allow for selection with this module.'); 
		$inputfields->add($f);

		/** @var InputfieldCheckbox $f */
		$f = $modules->get('InputfieldCheckbox');
		$f->attr('name', 'noSizeAttrs'); 
		$f->attr('value', 1);
		if(!empty($data['noSizeAttrs'])) $f->attr('checked', 'checked');
		$f->label = $this->_('Skip width attributes on image tags?'); // noSizeAttr label
		$f->description = $this->_('By default, this module will include width attributes in the img tag. If you are using responsive images, you might want to disable this behavior. Check the box to disable width and height attributes.'); // noSizeAttr description
		$f->notes = $this->_('We do not recommend checking this box as it will interfere with some features (like use of HiDPI/retina images).'); 
		$inputfields->add($f);

		$notes = $this->_('Recommended value:') . ' ';
		/** @var InputfieldText $f */
		$f = $modules->get('InputfieldText'); 
		$f->attr('name', 'alignLeftClass');
		$f->attr('value', $data['alignLeftClass']); 
		$f->label = $this->_('Align Image Left Class'); 
		$f->notes = $notes . '**align_left**';
		$f->columnWidth = 33; 
		$inputfields->add($f);

		/** @var InputfieldText $f */
		$f = $modules->get('InputfieldText'); 
		$f->attr('name', 'alignCenterClass');
		$f->attr('value', $data['alignCenterClass']); 
		$f->label = $this->_('Align Image Center Class');
		$f->notes = $notes . '**align_center**';
		$f->columnWidth = 34; 
		$inputfields->add($f);

		/** @var InputfieldText $f */
		$f = $modules->get('InputfieldText');
		$f->attr('name', 'alignRightClass');
		$f->attr('value', $data['alignRightClass']);
		$f->label = $this->_('Align Image Right Class');
		$f->notes = $notes . '**align_right**';
		$f->columnWidth = 33;
		$inputfields->add($f);

		return $inputfields;
	}

}
